diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 09d26c9..005e8e8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,9 +15,15 @@ jobs: sudo apt-get install -y shellcheck shellcheck -s sh install.sh scripts/check_install.sh scripts/check_piped_install.sh - name: Python syntax - run: python3 -m py_compile scripts/check_skills.py scripts/apply_addons.py bin/hstack-bench bin/hstack-test bin/hstack-pr bin/hstack-visdiff bin/hstack-scan bin/hstack-decisions + run: python3 -m py_compile scripts/check_skills.py scripts/apply_addons.py scripts/apply_hooks.py scripts/check_consent.py scripts/check_hooks.py scripts/check_stacks.py skills/hstack/hstack/scripts/hstack_stack.py hooks/hstack_hook.py hooks/guard.py hooks/save.py hooks/suggest.py bin/hstack-bench bin/hstack-test bin/hstack-pr bin/hstack-visdiff bin/hstack-scan bin/hstack-decisions - name: Skills run: python3 scripts/check_skills.py + - name: Consent + run: python3 scripts/check_consent.py + - name: Hooks + run: python3 scripts/check_hooks.py + - name: Stacks + run: python3 scripts/check_stacks.py - name: Installer run: sh scripts/check_install.sh - name: BSD tar diff --git a/PARITY.md b/PARITY.md index 82bed85..17e55da 100644 --- a/PARITY.md +++ b/PARITY.md @@ -25,7 +25,7 @@ Each row has one disposition: | `helper-script` | An original script under `bin/`, installed only with an addon flag. | | `skip` | Deliberately not built. The reason says why, and where the underlying job goes if it is real. | -`on_demand` is `0` for a skip, `skill:` when invoking that skill directly, or a route slug. A route slug is the upper bound of what `skill_view` loads when that route fires: the `hstack` skill, `references/routes.md`, the one playbook, and every `skill_view` that playbook names, including branches that return early. One extra hop adds `skill_view` targets inside an HStack skill body. Ship loads bundled `github` on that hop. Idle cost is only the index. It does not include those files. +`on_demand` is `0` for a skip, `skill:` when invoking that skill directly, or a route slug. A route slug is the upper bound of what `skill_view` loads when that route fires: the `hstack` skill, `references/routes.md`, the one playbook, `references/stacks.md` after that playbook, and every `skill_view` that playbook names, including branches that return early. One extra hop adds `skill_view` targets inside an HStack skill body. Ship loads bundled `github` on that hop. Idle cost is only the index. It does not include those files. Measured tokens use Hermes's estimator, `ceil(utf-8 bytes / 4)` with CJK at one token per character (`estimate_tokens_rough` in `agent/model_metadata.py`). Bundled skills use the frozen counts below, from hermes-agent `ee7e0f517232fb619c72e49d7e648436bddc598e`. A direct skill row costs only that skill's body. @@ -37,7 +37,7 @@ Idle is 204 for every session. The next table is the extra cost when a route fir | Stack | Idle index | |---|---:| -| HStack v0.1 | 204 | +| HStack v0.2 | 204 | | GStack on Hermes, prefixed names | 1,182 | | PStack if copied | 1,256 | | TStack if copied | 152 | @@ -45,48 +45,48 @@ Idle is 204 for every session. The next table is the extra cost when a route fir ## Route budget -Each playbook stays at or under its budget. `references/routes.md` is the catalog the router loads. The catalog measures 390 tokens. The `hstack` skill body stays at or under 700 tokens. Idle stays 204 because none of these files are index rows. +Each playbook stays at or under its budget. `references/routes.md` is the catalog the router loads. The catalog measures 390 tokens. The `hstack` skill body stays at or under 700 tokens. Idle stays 204 because none of these files are index rows. `references/stacks.md` is part of every route upper bound and is not part of the idle index. | route | when it fires | budget playbook tokens | measured playbook tokens | measured route upper bound | |---|---|---:|---:|---:| -| frame | Deciding what to build | 400 | 90 | 1445 | -| feature | New or changed behavior | 450 | 121 | 1444 | -| fix | Something is broken | 350 | 123 | 7191 | -| check | A diff needs a hard look | 400 | 145 | 3562 | -| land | The work should become a pull request | 300 | 69 | 1726 | -| refactor | Behavior-preserving restructure | 500 | 142 | 5776 | -| design | Visual critique, layout, developer friction | 500 | 209 | 5725 | -| qa | Reproduce on a real surface, or report only | 500 | 215 | 3356 | -| perf | A number, a trace of cost, a slow path | 500 | 164 | 1142 | -| trace | A live symptom or a captured profile | 500 | 110 | 757 | -| retro | Weekly retro or a quality snapshot | 500 | 92 | 1351 | -| docs | Docs, README, release notes, PDF | 500 | 121 | 4901 | -| security | Auth, injection, secrets, dependencies | 500 | 142 | 1183 | -| explain | How or why a subsystem works | 500 | 121 | 768 | -| memory | Save or restore working context | 500 | 106 | 753 | -| author | Write or edit a skill | 500 | 136 | 4446 | -| prose | Plain wording, strip tells | 500 | 110 | 11569 | -| experiment | Throwaway prototype | 500 | 87 | 2915 | -| parallel | Slices or competing attempts | 500 | 98 | 1204 | -| overnight | Finish while the user is away | 500 | 181 | 2563 | -| pickup | Resume, pause, or catch up | 500 | 109 | 992 | -| worktree | Clean worktrees | 500 | 62 | 709 | -| spec | Written spec or a phased plan | 500 | 143 | 1498 | -| eval | Test a prompt or skill change | 500 | 95 | 742 | -| deploy | Deploy or watch a host the user named | 500 | 146 | 1803 | -| browse | Drive a page, screenshot, scrape | 500 | 266 | 3076 | -| safety | Destructive commands or a write boundary | 500 | 117 | 764 | -| decisions | A reviewable decision trail | 500 | 93 | 740 | -| triage | Triage or reproduce a report | 500 | 79 | 726 | -| types | Types, including TypeScript | 500 | 114 | 1398 | -| verify | A property a test cannot reach | 500 | 141 | 788 | -| diagram | A diagram | 500 | 91 | 2196 | +| frame | Deciding what to build | 400 | 90 | 2457 | +| feature | New or changed behavior | 450 | 121 | 2456 | +| fix | Something is broken | 350 | 123 | 8203 | +| check | A diff needs a hard look | 400 | 145 | 4574 | +| land | The work should become a pull request | 300 | 69 | 2738 | +| refactor | Behavior-preserving restructure | 500 | 142 | 6788 | +| design | Visual critique, layout, developer friction | 500 | 209 | 6737 | +| qa | Reproduce on a real surface, or report only | 500 | 215 | 4368 | +| perf | A number, a trace of cost, a slow path | 500 | 164 | 2154 | +| trace | A live symptom or a captured profile | 500 | 110 | 1769 | +| retro | Weekly retro or a quality snapshot | 500 | 92 | 2363 | +| docs | Docs, README, release notes, PDF | 500 | 121 | 5913 | +| security | Auth, injection, secrets, dependencies | 500 | 142 | 2195 | +| explain | How or why a subsystem works | 500 | 121 | 1780 | +| memory | Save or restore working context | 500 | 154 | 1813 | +| author | Write or edit a skill | 500 | 136 | 5458 | +| prose | Plain wording, strip tells | 500 | 110 | 12581 | +| experiment | Throwaway prototype | 500 | 87 | 3927 | +| parallel | Slices or competing attempts | 500 | 98 | 2216 | +| overnight | Finish while the user is away | 500 | 181 | 3575 | +| pickup | Resume, pause, or catch up | 500 | 109 | 2004 | +| worktree | Clean worktrees | 500 | 62 | 1721 | +| spec | Written spec or a phased plan | 500 | 143 | 2510 | +| eval | Test a prompt or skill change | 500 | 95 | 1754 | +| deploy | Deploy or watch a host the user named | 500 | 146 | 2815 | +| browse | Drive a page, screenshot, scrape | 500 | 266 | 4088 | +| safety | Destructive commands or a write boundary | 500 | 150 | 1809 | +| decisions | A reviewable decision trail | 500 | 93 | 1752 | +| triage | Triage or reproduce a report | 500 | 79 | 1738 | +| types | Types, including TypeScript | 500 | 114 | 2410 | +| verify | A property a test cannot reach | 500 | 141 | 1800 | +| diagram | A diagram | 500 | 91 | 3208 | Direct skill loads, measured from the skill files in this tree. A `skill:` row costs this many tokens and does not load a playbook: | skill | measured body tokens | |---|---:| -| hstack | 257 | +| hstack | 344 | | reframe | 363 | | shape | 345 | | prove | 331 | diff --git a/README.md b/README.md index b4fd1fd..94f1d52 100644 --- a/README.md +++ b/README.md @@ -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. `HSTACK_VERSION=0.1.0` pins `hstack-v0.1.0.tar.gz` instead of the latest alias. The archive is one directory containing the skills, `scripts/apply_addons.py`, 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.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. 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. @@ -37,10 +37,10 @@ GStack's Hermes host puts every skill in the always-on index. HStack puts one ro | | GStack on Hermes | HStack | |---|---:|---:| | Idle index | 1,182 | 204 | -| Planning load | 62,514 of SKILL.md, plus a ~5,600 preamble | 965 | -| All SKILL.md bodies | 420,401 | 3,820 | +| Planning load | 62,514 of SKILL.md, plus a ~5,600 preamble | 1,052 | +| All SKILL.md bodies | 420,401 | 3,907 | -The planning path is `hstack`, `reframe`, and `shape`. 965 against 62,514 is under 2% of that context. The idle index is about 6× smaller. These HStack figures are measured from the files in this repo. 204 is the index cost of these descriptions, including the `hstack` category header. The v0.1 ceilings in PLAN.md are 2,500 and 8,550. +The planning path is `hstack`, `reframe`, and `shape`. 1,052 against 62,514 is under 2% of that context. The idle index is about 6× smaller. These HStack figures are measured from the files in this repo. 204 is the index cost of these descriptions, including the `hstack` category header. The v0.1 ceilings in PLAN.md are 2,500 and 8,550. ## Skills @@ -92,8 +92,14 @@ sh install.sh --uninstall --with browser | `test` | `hstack-test` | | `security` | `hstack-scan`, only if gitleaks or osv-scanner is already installed | | `trail` | `hstack-decisions` | +| `hermes-hooks` | `pre_tool_call` guard, `on_session_finalize` session stamp, `pre_llm_call` playbook hint | +| `hermes-hooks-guard` | The guard only | +| `hermes-hooks-save` | The session stamp only | +| `hermes-hooks-suggest` | The playbook hint only | -Helpers are copied to `$HERMES_HOME/hstack/bin`. MCP entries use an `hstack_` prefix in `config.yaml`. `--dry-run` writes nothing. `--uninstall` removes the skills and every addon this installer created. `--uninstall --with browser` removes only that addon. The installer does not download Playwright, gitleaks, or osv-scanner. +Helpers are copied to `$HERMES_HOME/hstack/bin`. MCP entries use an `hstack_` prefix in `config.yaml`. Hook shims are copied to `$HERMES_HOME/hstack/hooks` and registered under `hooks:` with a backup of `config.yaml`. A default install writes neither. `--dry-run` writes nothing. `--uninstall` removes the skills, the addons, and the hooks this installer created. `--uninstall --with browser` removes only that addon. `--uninstall --with hermes-hooks-guard` removes only that hook. The installer does not download Playwright, gitleaks, or osv-scanner, and it does not fetch another stack. + +Outside stacks stay out of the index. After one HStack playbook, the router may read `skills/hstack/hstack/references/stacks.md` and suggest at most one of gstack, pstack, tstack, or gbrain. It names the stack and why, and it loads nothing until you say yes. A no lasts for the session. If you say yes to installing, the script checks the pinned sha256 and extracts outside `skills/hstack`. It does not run that project's setup. ## Uninstall @@ -101,7 +107,7 @@ Helpers are copied to `$HERMES_HOME/hstack/bin`. MCP entries use an `hstack_` pr curl -fsSL https://hstack.sh/install | sh -s -- --uninstall ``` -From a checkout: `sh install.sh --uninstall`. This removes `skills/hstack` only when this installer created it, and it removes addons this installer created. +From a checkout: `sh install.sh --uninstall`. This removes `skills/hstack` only when this installer created it, and it removes addons and hooks this installer created. --- diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..0ea3a94 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +0.2.0 diff --git a/docs/v0.2-DESIGN.md b/docs/v0.2-DESIGN.md new file mode 100644 index 0000000..d5984d3 --- /dev/null +++ b/docs/v0.2-DESIGN.md @@ -0,0 +1,92 @@ +# HStack v0.2 design + +Optional hooks into Hermes, and an optional handoff to another stack. A default install still adds no hooks and no external route. The idle index stays the twelve description rows plus the `hstack` category header. + +Measured with the existing checker, `scripts/check_skills.py`, which uses Hermes's estimator `ceil(utf-8 bytes / 4)` (`estimate_tokens_rough` in `agent/model_metadata.py`). The target is the v0.1 figure of 204, and the hard ceiling is 230. + +## What was read + +Hermes Agent main at `0431192d69d4afd2443a7da15bb9e129a424414c` (28 October 2026). Hook names come from `hermes_cli/plugins.py` (`VALID_HOOKS`, last touched in `2eec2a7d2401e0e4c78454c7bf50ab327348c6d8`) and from the event catalog: + +- https://hermes-agent.nousresearch.com/docs/user-guide/features/hooks +- https://github.com/NousResearch/hermes-agent/blob/main/website/docs/user-guide/features/hooks.md +- https://github.com/NousResearch/hermes-agent/blob/main/website/docs/user-guide/features/plugins.md +- https://github.com/NousResearch/hermes-agent/blob/main/hermes_cli/plugins.py + +The terminal tool's required argument is `command` (`tools/terminal_tool.py` in that tree, schema `required: ["command"]`). The shell-hook example in the hooks doc puts that value at `tool_input.command`. + +I did not run Hermes. The dispatch, the first-use allowlist prompt, and the `extra` object are from those docs and from `VALID_HOOKS`, not from a live session. `pre_llm_call` kwargs are documented as landing in the shell payload's `extra` dict. `is_first_turn` is a documented callback argument, so the suggest shim reads `extra.is_first_turn`. If a build omits it, the shim injects nothing. + +## Hooks that earn a place + +Hermes has four hook systems. Gateway hooks (`~/.hermes/hooks/*/HOOK.yaml`) do not run in the CLI. Python plugins load only after `plugins.enabled` and call `ctx.register_hook` inside the process. Shell hooks are a `hooks:` list in the profile `config.yaml`, spawned as a subprocess, and they run in the CLI, gateway, Desktop, TUI, and dashboard chat. That is the mechanism these shims use. HStack does not register a plugin, does not set `plugins.enabled`, and does not set `hooks_auto_accept`. + +Each installed hook is a few lines that call `hooks/hstack_hook.py`. The skills do not import it. Uninstalling the hooks leaves the skills working. + +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:` and the files this installer wrote. Another product's hook stays. + +### `guard` — `pre_tool_call` on `terminal` + +`pre_command` is in `VALID_HOOKS`, and it fires before a recognized slash command. The same entry says returns are ignored in v1. It cannot block `rm`. The hook that can block a tool is `pre_tool_call`. A shell hook selects the tool with `matcher`, and `fail_closed: true` is honored only on `pre_tool_call`. + +The guard matches `terminal` and fails closed. It blocks a short list that matches the safety playbook's destructive case: `rm -rf` of `/`, `/*`, `~`, or `$HOME`; `mkfs`; `dd` onto a disk device; a fork bomb; `shutdown`, `reboot`, `poweroff`, or `halt`. It does not try to be the write sandbox. Hermes already has `approvals.mode`, the command blocklist, and the write boundary. The playbook tells the agent not to install a hook by itself. This one is only the installer flag. + +There is no separate "pre-command safety" event that can block. Using `pre_command` here would be a comment, not a guard. + +### `save` — `on_session_finalize` + +`on_session_end` is the wrong event for "session end". The catalog says it fires at the end of every `run_conversation()` call, and again from the CLI exit handler if a turn was in flight. Its return is ignored. `on_session_finalize` fires when the CLI or gateway tears down the session (`/new`, quit, gateway reset or shutdown). That is the session boundary. + +The shim appends one line to `$HERMES_HOME/hstack/session-log.tsv`: time, session id, platform. It does not write the message. The memory playbook may read the last line when the file exists, and it does nothing when the file is absent. The skills do not require the hook. + +`on_session_finalize` can also return a `message` for the user. This shim returns `{}` so it does not talk over the session. + +### `suggest` — `pre_llm_call` + +Nothing in `VALID_HOOKS` is a "suggest a skill" event. `on_session_start` and `on_skill_lifecycle` are observers. Their returns are not injected. `pre_llm_call` runs once per user turn, before the tool loop, and a `{"context": "..."}` return is appended to that turn's user message. It is not part of the cached system prompt, so it is not idle-index cost. + +The shim injects one line, and only when `extra.is_first_turn` is true, the message does not already name an HStack skill, and a keyword maps to one playbook. It names `references/.md`. It does not name another stack, and it does not load one. Later turns pay nothing. If the field is missing, it injects nothing. + +## External stacks + +The router picks an HStack playbook first (`references/routes.md`, then one playbook). Only then does it load `references/stacks.md`. That file is not an index row, so the idle count does not move. The route upper bound in `PARITY.md` includes it, because the router loads it. + +`stacks.md` is the registry. A fenced JSON block is what `hstack_stack.py` reads. Each entry has what the stack is for, how to detect it without running it, the license, the pinned commit, the sha256 of the codeload archive, and the archive URL. The hashes below are of the `.tar.gz` from `https://codeload.github.com///tar.gz/`, downloaded 28 October 2026. TStack's hash was checked twice and matched. + +| Id | Commit | sha256 | Archive bytes | License | +|---|---|---|---:|---| +| gstack | `9e3308f65adf547b7e4776e5ed22467a2649d2ee` | `3beaffdfc83012c6784c7f0610b580290c7e0b41a416e230d4ed63df7311ebc7` | 21952109 | MIT, Copyright (c) 2026 Garry Tan | +| pstack | `c742dd61706fbacc1b95f1a20b7b988cf096ce6c` | `4f899c71d737afe7662ff515df0518b193562e4f9ec370ffb2d7d837aaf436ef` | 8306955 | MIT, Copyright (c) 2026 Lauren Tan. The archive is the whole `cursor/plugins` tree. Skills are under `pstack/`. | +| tstack | `1085050f3baed1ac724caa4a8166145fb2ac88af` | `9ef4b8abe9a8595f2309fdb096e4c41569b26920bcf6e91fba07854a9092cf1c` | 23424 | MIT, Copyright (c) 2026 Theo Browne | +| gbrain | `82b7ef601fdfc449b0d242f7e61a4604aa2c8a8e` | `b7127348f6ed1163c32106957844af637426d491c669a3ee68e989f2c68ed451` | 50447607 | MIT, Copyright (c) 2026 Garry Tan | + +Detection does not execute the other project: + +- **gstack.** A `SKILL.md` under `${HERMES_HOME:-~/.hermes}/skills/gstack/`, or the file `~/.claude/skills/gstack/bin/gstack-state-root.sh` (the same file is also checked under `~/.codex/skills/gstack/bin/`). Those paths are from `docs/hosts.md` and `docs/install.md` at the pinned gstack commit. `~/.gstack` is state (`docs/state-root.md`, `GSTACK_HOME`). It is not treated as an install. The marker script is not run. +- **pstack.** A file whose path ends in `skills/poteto-mode/SKILL.md` under `~/.cursor/plugins` or `~/.claude/plugins`, searched a few directories deep and not through symlinks. That is the file `pstack/agents/poteto-agent.md` tells a subagent to find. Install is `/add-plugin pstack` in Cursor, or the Claude marketplace command in `pstack/README.md`. There is no standalone pstack repository. The pin is `cursor/plugins` at the commit in `RESEARCH.md`. +- **tstack.** One directory contains both `leaving-pr-comments/SKILL.md` and `sol-loop/SKILL.md`. The roots are `~/.claude/skills`, `~/.codex/skills`, `~/.agents/skills`, and the Hermes skills directory. The README at the pinned commit installs with `cp -R tstack/skills/*` into the Claude or Codex skills directory. A clone anywhere else is not detected. +- **gbrain.** `config.json` under the directory `configDir()` returns. In `src/core/config.ts` at the pinned commit, `GBRAIN_HOME` is a parent and the code appends `.gbrain`. Unset, the file is `~/.gbrain/config.json`. An `mcp_servers.gbrain` entry in the Hermes `config.yaml` also counts. A `gbrain` binary on `PATH` does not. Their Hermes page (`docs/mcp/HERMES.md`) registers `gbrain serve`. This design does not run that, and it does not add the MCP server. Scaffolded skill trees are not scanned outside the roots above. `gbrain-upgrade/SKILL.md` next to `brain-ops/SKILL.md` under those roots counts, because `skills/manifest.json` ships both. + +A receipt at `$HERMES_HOME/hstack/external///.hstack-external` also counts. That tree is produced only by the install command below. It is not copied into `skills/hstack`, and it is not added to `skills.external_dirs`. Adding it there would put the other stack in the idle index. GStack's Hermes host is 1,182 idle tokens in `PARITY.md`. + +The router suggests at most one stack. It prefers an installed stack when that stack fits the task better than the playbook already loaded. One line, name and why, then a question. Nothing from that stack is loaded until the user says yes. If none of the installed stacks fit, it may name one that is not installed and show the install command. It still waits for a yes. A no is `decline`, stored for that session id under `$HERMES_HOME/hstack/declines/`. Detect hides a declined id from the suggestion. The session id is whatever the agent passes. Hermes does not give this script a session id of its own. + +HStack never fetches a stack on its own. `install` without `--yes` exits before any network call. `--yes` is the user's yes for that session, and the command refuses a session that has already declined. The download is the pinned archive URL. The sha256 in the registry is checked before extract, the same gate as `install.sh` against `/dl/hstack.tar.gz.sha256`: magic bytes, digest match, no absolute member, no `..`, no link. A mismatch deletes the temp file and writes nothing. The extract directory is `$HERMES_HOME/hstack/external///`. The script does not run the other project's setup. Setup would copy skills into the Hermes index and, for gstack, can download a browser. Hand off means printing the path and stopping. + +Their own text is not committed in this repo. The registry records the pin, the checksum, the license line, and a one-line description written here. + +## Consent is a check, not a paragraph + +`scripts/check_consent.py` fails the build if any HStack skill or playbook other than `references/stacks.md` contains an external archive URL, an `hstack_stack.py install` invocation, or a `skill_view` of a skill this repo does not own. `stacks.md` must say "at most one", must say "until the user says yes" before the install command, and must mention `decline` and `--yes`. The hook sources are scanned for the same archive URLs. The script's own tests cover a playbook that loads an outside skill with no question, and an install line with no yes. Those strings are not files in the tree. + +The install command also refuses to download without `--yes`. Both gates have to hold. + +## Installer version + +Skill frontmatter, `VERSION`, and the archive name move to 0.2.0. `HSTACK_VERSION=0.1.0` still requests `/dl/hstack-v0.1.0.tar.gz`. The site build copies the bytes already published at that URL (sha256 `0c8009ee04fb31126b76e4202c91eee14e8d5be7d1cd42ac0cee551012eeda6e`) and does not rebuild them. The latest alias `/dl/hstack.tar.gz` becomes the 0.2.0 archive. `/dl/hstack-v0.2.0.tar.gz` is the pinned name. Cache for the two versioned archives is immutable. The alias stays a five-minute cache, which is the existing rule, because its bytes change on this release. + +## What stays out + +No Python plugin. No `hooks_auto_accept`. No edit to `plugins.enabled`. No MCP server for gbrain. No copy of another stack's `SKILL.md`. No automatic `skills.external_dirs` entry. The default `install.sh` path does not write `hooks:`. diff --git a/hooks/guard.py b/hooks/guard.py new file mode 100644 index 0000000..acf3cec --- /dev/null +++ b/hooks/guard.py @@ -0,0 +1,8 @@ +#!/usr/bin/env python3 +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import hstack_hook + +raise SystemExit(hstack_hook.guard(sys.stdin.read())) diff --git a/hooks/hstack_hook.py b/hooks/hstack_hook.py new file mode 100644 index 0000000..3660ce6 --- /dev/null +++ b/hooks/hstack_hook.py @@ -0,0 +1,245 @@ +"""Hook behavior for the optional Hermes shell shims. + +The shims in this directory import this module. Nothing under skills/ does. +Hermes passes a JSON object on stdin. See the shell-hook wire format in +website/docs/user-guide/features/hooks.md. +""" + +from __future__ import annotations + +import json +import os +import re +from datetime import datetime, timezone +from pathlib import Path + + +ROOT_RM = re.compile( + r"^(?:/|/\*|~|~/|~/\*|\$HOME|\$\{HOME\}|%USERPROFILE%|[A-Za-z]:\\?|[A-Za-z]:/\*?|[A-Za-z]:\\\*?)$", + re.IGNORECASE, +) +RM_FLAGS = re.compile(r"^-[a-zA-Z]*$") +FORK = re.compile(r":\(\)\s*\{") +POWER = re.compile(r"(?:^|[;&|`\n])\s*(?:sudo\s+)?(?:shutdown|reboot|poweroff|halt)\b") +MKFS = re.compile(r"(?:^|[;&|`\n])\s*(?:sudo\s+)?mkfs(?:\.[a-z0-9]+)?\b") +DD_DISK = re.compile(r"\bdd\b[\s\S]{0,200}\bof=(?:/dev/|\\\\)") + + +def hermes_home() -> Path: + raw = os.environ.get("HERMES_HOME") + if raw: + return Path(raw) + return Path.home() / ".hermes" + + +def _load(raw: str) -> dict: + data = json.loads(raw) + if not isinstance(data, dict): + raise ValueError("payload is not an object") + return data + + +def _command(payload: dict) -> str: + tool_input = payload.get("tool_input") + if isinstance(tool_input, dict): + command = tool_input.get("command") + if isinstance(command, str): + return command + extra = payload.get("extra") + if isinstance(extra, dict): + args = extra.get("args") + if isinstance(args, dict) and isinstance(args.get("command"), str): + return args["command"] + return "" + + +def destructive(command: str) -> str | None: + """Return a short reason when the command should not run.""" + text = command.strip() + if not text: + return None + if FORK.search(text): + return "fork bomb" + if POWER.search(text): + return "power command" + if MKFS.search(text): + return "mkfs" + if DD_DISK.search(text): + return "disk write" + for segment in re.split(r"[;&|`\n]", text): + tokens = segment.split() + if not tokens: + continue + if tokens[0] == "sudo": + tokens = tokens[1:] + if not tokens or tokens[0] != "rm": + continue + flags = [] + operands = [] + for token in tokens[1:]: + if token == "--": + continue + if token.startswith("-") and RM_FLAGS.match(token): + flags.append(token[1:]) + else: + operands.append(token) + letters = "".join(flags) + if "r" not in letters or "f" not in letters: + continue + for operand in operands: + if ROOT_RM.match(operand): + return "rm of a root" + return None + + +def guard(raw: str) -> int: + try: + payload = _load(raw) + except (json.JSONDecodeError, ValueError): + print(json.dumps({"action": "block", "message": "hstack guard could not read the tool call"})) + return 2 + tool = payload.get("tool_name") + if tool not in (None, "terminal"): + print("{}") + return 0 + command = _command(payload) + if tool == "terminal" and not command: + print(json.dumps({"action": "block", "message": "hstack guard could not read the terminal command"})) + return 2 + reason = destructive(command) + if reason: + print(json.dumps({"action": "block", "message": f"hstack guard blocked a {reason}"})) + return 0 + print("{}") + return 0 + + +def _session_id(payload: dict) -> str: + for key in ("session_id",): + value = payload.get(key) + if isinstance(value, str) and value.strip(): + return value.strip()[:80] + extra = payload.get("extra") + if isinstance(extra, dict): + value = extra.get("session_id") + if isinstance(value, str) and value.strip(): + return value.strip()[:80] + return "unknown" + + +def _platform(payload: dict) -> str: + extra = payload.get("extra") + if isinstance(extra, dict) and isinstance(extra.get("platform"), str): + return extra["platform"].strip()[:40] or "unknown" + if isinstance(payload.get("platform"), str): + return payload["platform"].strip()[:40] or "unknown" + return "unknown" + + +def save(raw: str) -> int: + try: + payload = _load(raw) + except (json.JSONDecodeError, ValueError): + print("{}") + return 0 + home = hermes_home() + try: + directory = home / "hstack" + directory.mkdir(parents=True, exist_ok=True) + line = "\t".join( + ( + datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), + _session_id(payload).replace("\t", " "), + _platform(payload).replace("\t", " "), + ) + ) + with (directory / "session-log.tsv").open("a", encoding="utf-8") as handle: + handle.write(line + "\n") + except OSError: + print("{}") + return 0 + print("{}") + return 0 + + +_SKILLS = ( + "reframe", + "shape", + "prove", + "council", + "ship", + "takeover", + "modernize", + "blast", + "swarm", + "arena", + "principles", + "hstack", +) + + +def _message_text(payload: dict) -> str: + extra = payload.get("extra") if isinstance(payload.get("extra"), dict) else {} + value = extra.get("user_message") + if value is None: + value = payload.get("user_message") + if isinstance(value, str): + return value + if isinstance(value, list): + parts = [] + for item in value: + if isinstance(item, str): + parts.append(item) + elif isinstance(item, dict) and isinstance(item.get("text"), str): + parts.append(item["text"]) + return "\n".join(parts) + return "" + + +def _first_turn(payload: dict) -> bool: + extra = payload.get("extra") if isinstance(payload.get("extra"), dict) else {} + flag = extra.get("is_first_turn", payload.get("is_first_turn")) + return flag is True + + +def _playbook(message: str) -> str | None: + text = message.lower() + if any(name in text for name in ("/hstack",) + tuple(f"/{name}" for name in _SKILLS)): + return None + if any(name in text.split() for name in _SKILLS): + return None + rules = ( + (("security", "injection", "secret", "auth"), "security.md"), + (("deploy", "production"), "deploy.md"), + (("diagram",), "diagram.md"), + (("pull request", "merge"), "land.md"), + (("refactor",), "refactor.md"), + (("layout", "design"), "design.md"), + (("broken", "crash", "bug"), "fix.md"), + (("implement", "add a", "build"), "feature.md"), + ) + for words, filename in rules: + if any(word in text for word in words): + return filename + return None + + +def suggest(raw: str) -> int: + try: + payload = _load(raw) + except (json.JSONDecodeError, ValueError): + print("{}") + return 0 + if not _first_turn(payload): + print("{}") + return 0 + filename = _playbook(_message_text(payload)) + if not filename: + print("{}") + return 0 + context = ( + f"HStack playbook {filename} matches this task. " + "Load it through the hstack router. Do not load another stack unless the user asks." + ) + print(json.dumps({"context": context})) + return 0 diff --git a/hooks/save.py b/hooks/save.py new file mode 100644 index 0000000..229dd64 --- /dev/null +++ b/hooks/save.py @@ -0,0 +1,8 @@ +#!/usr/bin/env python3 +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import hstack_hook + +raise SystemExit(hstack_hook.save(sys.stdin.read())) diff --git a/hooks/suggest.py b/hooks/suggest.py new file mode 100644 index 0000000..01bedc2 --- /dev/null +++ b/hooks/suggest.py @@ -0,0 +1,8 @@ +#!/usr/bin/env python3 +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import hstack_hook + +raise SystemExit(hstack_hook.suggest(sys.stdin.read())) diff --git a/install.ps1 b/install.ps1 index 0a8dc52..f4c6d61 100644 --- a/install.ps1 +++ b/install.ps1 @@ -8,8 +8,10 @@ $ErrorActionPreference = 'Stop' $Dry = $false $Uninstall = $false -$With = New-Object System.Collections.Generic.List[string] $AddonNames = @('browser', 'github', 'docs', 'search', 'perf', 'test', 'security', 'trail') +$HookNames = @('hermes-hooks', 'hermes-hooks-guard', 'hermes-hooks-save', 'hermes-hooks-suggest') +$AddonWith = New-Object System.Collections.Generic.List[string] +$HookWith = New-Object System.Collections.Generic.List[string] function Die([string]$Message) { [Console]::Error.WriteLine("hstack: $Message") @@ -27,23 +29,29 @@ Usage: powershell -File install.ps1 [--dry-run] [--uninstall] [--with name,name] Installs into the Hermes home, at skills\hstack. The usual home is .hermes in your profile directory. The default install is skills only. --with adds an optional addon: browser, github, docs, search, perf, test, security, trail. +--with hermes-hooks adds optional shell hooks. Subsets are +hermes-hooks-guard, hermes-hooks-save, and hermes-hooks-suggest. The download form fetches the archive from hstack.sh. HSTACK_BASE_URL overrides that host. HSTACK_VERSION pins a release. Running this file from a git checkout installs that checkout. '@ } -function Add-AddonNames([string]$Raw) { +function Add-WithNames([string]$Raw) { if ([string]::IsNullOrWhiteSpace($Raw)) { Die '--with needs a comma-separated list' } foreach ($name in $Raw.Split(',')) { $trimmed = $name.Trim() - if (-not $trimmed -or $AddonNames -notcontains $trimmed) { - Die "unknown addon: $trimmed" + if (-not $trimmed) { + Die 'unknown addon: empty' } - if (-not $With.Contains($trimmed)) { - [void]$With.Add($trimmed) + if ($AddonNames -contains $trimmed) { + if (-not $AddonWith.Contains($trimmed)) { [void]$AddonWith.Add($trimmed) } + } elseif ($HookNames -contains $trimmed) { + if (-not $HookWith.Contains($trimmed)) { [void]$HookWith.Add($trimmed) } + } else { + Die "unknown addon: $trimmed" } } } @@ -59,13 +67,13 @@ while ($index -lt $args.Count) { Show-Usage exit 0 } elseif ($arg.StartsWith('--with=')) { - Add-AddonNames $arg.Substring(7) + Add-WithNames $arg.Substring(7) } elseif ($arg -eq '--with') { $index++ if ($index -ge $args.Count) { Die '--with needs a comma-separated list' } - Add-AddonNames ([string]$args[$index]) + Add-WithNames ([string]$args[$index]) } else { Die "unknown argument: $arg" } @@ -122,25 +130,22 @@ if ($ScriptDir -and (Test-Path -LiteralPath (Join-Path $ScriptDir 'skills\hstack $AddonRoot = $ScriptDir } -function Invoke-Addons([string]$ModeName) { - if ($ModeName -ne 'uninstall' -and $With.Count -eq 0) { - return - } - $addonDir = Join-Path $FullHome 'hstack' - if ($ModeName -eq 'uninstall' -and $With.Count -eq 0 -and -not (Test-Path -LiteralPath $addonDir)) { +function Invoke-PythonTool([string]$Kind, [string]$ScriptName, $Names, [string]$ModeName) { + if ($ModeName -ne 'uninstall' -and $Names.Count -eq 0) { return } + $toolDir = Join-Path $FullHome 'hstack' $python = Get-Command python -ErrorAction SilentlyContinue if (-not $python) { $python = Get-Command python3 -ErrorAction SilentlyContinue } if (-not $python) { - Die 'python is required for addons' + Die "python is required for $Kind" } $py = $null - $repoPy = if ($AddonRoot) { Join-Path $AddonRoot 'scripts\apply_addons.py' } else { $null } - $homePy = Join-Path $addonDir 'apply_addons.py' - $scriptPy = if ($ScriptDir) { Join-Path $ScriptDir 'scripts\apply_addons.py' } else { $null } + $repoPy = if ($AddonRoot) { Join-Path $AddonRoot "scripts\$ScriptName" } else { $null } + $homePy = Join-Path $toolDir $ScriptName + $scriptPy = if ($ScriptDir) { Join-Path $ScriptDir "scripts\$ScriptName" } else { $null } foreach ($candidate in @($repoPy, $homePy, $scriptPy)) { if ($candidate -and (Test-Path -LiteralPath $candidate)) { $py = $candidate @@ -149,10 +154,14 @@ function Invoke-Addons([string]$ModeName) { } if (-not $py) { if ($Dry) { - Say "dry-run: would configure addons: $($With -join ',')" + $shown = if ($Names.Count -gt 0) { $Names -join ',' } else { 'all' } + Say "dry-run: would configure ${Kind}: $shown" return } - Die 'missing scripts/apply_addons.py' + if ($ModeName -eq 'uninstall' -and -not (Test-Path -LiteralPath $toolDir)) { + return + } + Die "missing scripts/$ScriptName" } $pyArgs = @($py, '--hermes-home', $FullHome) if ($AddonRoot) { @@ -160,17 +169,48 @@ function Invoke-Addons([string]$ModeName) { } if ($Dry) { $pyArgs += '--dry-run' } if ($ModeName -eq 'uninstall') { $pyArgs += '--uninstall' } - if ($With.Count -gt 0) { $pyArgs += @('--with', ($With -join ',')) } + if ($Names.Count -gt 0) { $pyArgs += @('--with', ($Names -join ',')) } & $python.Source @pyArgs if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } } -if ($Uninstall -and $With.Count -gt 0) { - Invoke-Addons 'uninstall' +function Invoke-Addons([string]$ModeName) { + $marker = Join-Path (Join-Path $FullHome 'hstack') '.hstack-addons' + if ($ModeName -eq 'uninstall' -and $AddonWith.Count -eq 0 -and -not (Test-Path -LiteralPath $marker)) { + return + } + Invoke-PythonTool 'addons' 'apply_addons.py' $AddonWith $ModeName +} + +function Invoke-Hooks([string]$ModeName) { + $root = Join-Path $FullHome 'hstack' + $marker = Join-Path $root '.hstack-hooks' + $state = Join-Path $root 'hooks.json' + if ($ModeName -eq 'uninstall' -and $HookWith.Count -eq 0 -and -not (Test-Path -LiteralPath $marker) -and -not (Test-Path -LiteralPath $state)) { + return + } + Invoke-PythonTool 'hooks' 'apply_hooks.py' $HookWith $ModeName +} + +function Test-CreatedConfig { + foreach ($name in @('addons.json', 'hooks.json')) { + $state = Join-Path (Join-Path $FullHome 'hstack') $name + if (Test-Path -LiteralPath $state) { + $text = [System.IO.File]::ReadAllText($state) + if ($text.Contains('"created_config": true')) { return $true } + } + } + return $false +} + +if ($Uninstall -and ($AddonWith.Count -gt 0 -or $HookWith.Count -gt 0)) { + if ($AddonWith.Count -gt 0) { Invoke-Addons 'uninstall' } + if ($HookWith.Count -gt 0) { Invoke-Hooks 'uninstall' } exit 0 } if ($Uninstall) { + $configWasOurs = Test-CreatedConfig if (-not (Test-Path -LiteralPath $Dest)) { Say "nothing to remove at $Dest" } else { @@ -189,6 +229,14 @@ if ($Uninstall) { } } Invoke-Addons 'uninstall' + Invoke-Hooks 'uninstall' + if (-not $Dry -and $configWasOurs) { + $configPath = Join-Path $FullHome 'config.yaml' + if (Test-Path -LiteralPath $configPath) { + $flat = ([System.IO.File]::ReadAllText($configPath) -replace '\s', '') + if (-not $flat) { Remove-Item -LiteralPath $configPath -Force } + } + } exit 0 } @@ -227,6 +275,7 @@ if ($Dry) { Say "dry-run: would verify $Sum" } Invoke-Addons 'install' + Invoke-Hooks 'install' exit 0 } @@ -299,7 +348,7 @@ if ($links.Count -gt 0) { Die 'refusing to copy a link or special file from the skill tree' } -$ver = '0.1.0' +$ver = '0.2.0' $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.]', '') @@ -342,6 +391,7 @@ if (Test-Path -LiteralPath $Dest) { Say "installed into $Dest" Invoke-Addons 'install' +Invoke-Hooks 'install' if ($Mode -eq 'remote') { $downloaded = Split-Path -Parent $AddonRoot if ($downloaded -and (Test-Path -LiteralPath $downloaded)) { diff --git a/install.sh b/install.sh index 896c766..4361cab 100755 --- a/install.sh +++ b/install.sh @@ -8,7 +8,8 @@ # any other skill, and it will not delete a directory it did not mark. # The curl form downloads the archive from hstack.sh. The repository is private, # so this script does not fetch from a git host. The archive also carries the -# addon helpers under bin/ and scripts/apply_addons.py. +# addon helpers under bin/, scripts/apply_addons.py, scripts/apply_hooks.py, +# and hooks/. set -eu @@ -28,6 +29,8 @@ usage() { 'Installs into the Hermes home, at skills/hstack. The usual home is .hermes in your home directory.' \ 'The default install is skills only. --with adds an optional addon:' \ 'browser, github, docs, search, perf, test, security, trail.' \ + '--with hermes-hooks adds optional shell hooks. Subsets are' \ + 'hermes-hooks-guard, hermes-hooks-save, and hermes-hooks-suggest.' \ 'The curl form downloads the archive from hstack.sh.' \ 'HSTACK_BASE_URL overrides that host. HSTACK_VERSION pins a release.' \ 'Running this file from a git checkout installs that checkout.' @@ -55,6 +58,9 @@ cleanup() { trap cleanup EXIT +ADDON_WITH= +HOOK_WITH= + addon_ok() { case "$1" in browser|github|docs|search|perf|test|security|trail) return 0 ;; @@ -62,6 +68,13 @@ addon_ok() { esac } +hook_ok() { + case "$1" in + hermes-hooks|hermes-hooks-guard|hermes-hooks-save|hermes-hooks-suggest) return 0 ;; + *) return 1 ;; + esac +} + while [ $# -gt 0 ]; do case "$1" in --dry-run) DRY=1; shift ;; @@ -95,8 +108,15 @@ if [ -n "$WITH" ]; then *,*) _rest=${_rest#*,} ;; *) _rest= ;; esac - if [ -z "$_addon" ] || ! addon_ok "$_addon"; then - die "unknown addon: ${_addon:-empty}" + if [ -z "$_addon" ]; then + die "unknown addon: empty" + fi + if addon_ok "$_addon"; then + ADDON_WITH="${ADDON_WITH:+$ADDON_WITH,}${_addon}" + elif hook_ok "$_addon"; then + HOOK_WITH="${HOOK_WITH:+$HOOK_WITH,}${_addon}" + else + die "unknown addon: $_addon" fi done fi @@ -176,32 +196,32 @@ if [ -f "$script" ]; then SCRIPT_DIR=$(CDPATH='' cd "$(dirname "$script")" && pwd -P) fi -run_addons() { - _mode=$1 - if [ "$_mode" != uninstall ] && [ -z "$WITH" ]; then - return 0 - fi - if [ "$_mode" = uninstall ] && [ -z "$WITH" ] && [ ! -e "$HERMES_HOME/hstack" ]; then +run_python() { + _kind=$1 + _script=$2 + _names=$3 + _mode=$4 + if [ "$_mode" != uninstall ] && [ -z "$_names" ]; then return 0 fi - command -v python3 >/dev/null 2>&1 || die "python3 is required for addons" + command -v python3 >/dev/null 2>&1 || die "python3 is required for $_kind" _py= - if [ -n "$ADDON_ROOT" ] && [ -f "$ADDON_ROOT/scripts/apply_addons.py" ]; then - _py=$ADDON_ROOT/scripts/apply_addons.py - elif [ -f "$HERMES_HOME/hstack/apply_addons.py" ]; then - _py=$HERMES_HOME/hstack/apply_addons.py - elif [ -n "$SCRIPT_DIR" ] && [ -f "$SCRIPT_DIR/scripts/apply_addons.py" ]; then - _py=$SCRIPT_DIR/scripts/apply_addons.py + if [ -n "$ADDON_ROOT" ] && [ -f "$ADDON_ROOT/scripts/$_script" ]; then + _py=$ADDON_ROOT/scripts/$_script + elif [ -f "$HERMES_HOME/hstack/$_script" ]; then + _py=$HERMES_HOME/hstack/$_script + elif [ -n "$SCRIPT_DIR" ] && [ -f "$SCRIPT_DIR/scripts/$_script" ]; then + _py=$SCRIPT_DIR/scripts/$_script fi if [ -z "$_py" ]; then if [ "$_mode" = uninstall ] && [ ! -d "$HERMES_HOME/hstack" ]; then return 0 fi if [ "$DRY" -eq 1 ]; then - say "dry-run: would configure addons: ${WITH:-all}" + say "dry-run: would configure $_kind: ${_names:-all}" return 0 fi - die "missing scripts/apply_addons.py" + die "missing scripts/$_script" fi _source=$ADDON_ROOT if [ -z "$_source" ] && [ -n "$SCRIPT_DIR" ]; then @@ -217,18 +237,60 @@ run_addons() { if [ "$_mode" = uninstall ]; then set -- "$@" --uninstall fi - if [ -n "$WITH" ]; then - set -- "$@" --with "$WITH" + if [ -n "$_names" ]; then + set -- "$@" --with "$_names" fi "$@" } +run_addons() { + _mode=$1 + if [ "$_mode" = uninstall ] && [ -z "$ADDON_WITH" ] && [ ! -e "$HERMES_HOME/hstack/.hstack-addons" ]; then + return 0 + fi + run_python addons apply_addons.py "$ADDON_WITH" "$_mode" +} + +run_hooks() { + _mode=$1 + if [ "$_mode" = uninstall ] && [ -z "$HOOK_WITH" ] && [ ! -e "$HERMES_HOME/hstack/.hstack-hooks" ] && [ ! -e "$HERMES_HOME/hstack/hooks.json" ]; then + return 0 + fi + run_python hooks apply_hooks.py "$HOOK_WITH" "$_mode" +} + +drop_empty_config() { + if [ "$1" -ne 1 ] || [ ! -f "$HERMES_HOME/config.yaml" ]; then + return 0 + fi + _flat=$(tr -d '[:space:]' < "$HERMES_HOME/config.yaml" || true) + if [ -z "$_flat" ]; then + rm -f "$HERMES_HOME/config.yaml" + fi +} + +config_was_ours() { + _ours=0 + for _state in "$HERMES_HOME/hstack/addons.json" "$HERMES_HOME/hstack/hooks.json"; do + if [ -f "$_state" ] && grep -q '"created_config": true' "$_state"; then + _ours=1 + fi + done + printf '%s\n' "$_ours" +} + if [ "$UNINSTALL" -eq 1 ] && [ -n "$WITH" ]; then - run_addons uninstall + if [ -n "$ADDON_WITH" ]; then + run_addons uninstall + fi + if [ -n "$HOOK_WITH" ]; then + run_hooks uninstall + fi exit 0 fi if [ "$UNINSTALL" -eq 1 ]; then + _ours=$(config_was_ours) if [ ! -d "$DEST" ]; then say "nothing to remove at $DEST" else @@ -243,6 +305,10 @@ if [ "$UNINSTALL" -eq 1 ]; then fi fi run_addons uninstall + run_hooks uninstall + if [ "$DRY" -eq 0 ]; then + drop_empty_config "$_ours" + fi exit 0 fi @@ -295,6 +361,7 @@ if [ "$DRY" -eq 1 ]; then say "dry-run: would verify $SUM" fi run_addons install + run_hooks install exit 0 fi @@ -379,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.1.0 +ver=0.2.0 if [ -n "$ADDON_ROOT" ] && [ -f "$ADDON_ROOT/VERSION" ]; then got=$(tr -cd '0-9.' < "$ADDON_ROOT/VERSION") if [ -n "$got" ]; then @@ -422,3 +489,4 @@ fi say "installed into $DEST" run_addons install +run_hooks install diff --git a/scripts/apply_addons.py b/scripts/apply_addons.py index aa2adbd..2eb248b 100644 --- a/scripts/apply_addons.py +++ b/scripts/apply_addons.py @@ -193,13 +193,22 @@ def write_state(path: Path, state: dict) -> None: path.write_text(json.dumps(state, indent=2) + "\n", encoding="utf-8") +def hooks_owned(dest_root: Path) -> bool: + return (dest_root / ".hstack-hooks").is_file() or (dest_root / "hooks.json").is_file() + + def ensure_home_dir(dest_root: Path, dry_run: bool) -> None: if dest_root.is_symlink(): die(f"refusing to follow symlink {dest_root}") marker = dest_root / MARKER_NAME if dest_root.exists() and not dest_root.is_dir(): die(f"refusing {dest_root} (not a directory)") - if dest_root.is_dir() and any(dest_root.iterdir()) and not marker.is_file(): + if ( + dest_root.is_dir() + and any(dest_root.iterdir()) + and not marker.is_file() + and not hooks_owned(dest_root) + ): die(f"refusing to overwrite non-empty {dest_root} (not an HStack addon install)") if dry_run: return @@ -299,10 +308,15 @@ def main() -> int: state_path = dest_root / "addons.json" config = home / "config.yaml" uninstall_all = args.uninstall and not requested - if args.uninstall and not dest_root.exists(): + if args.uninstall and not (dest_root / MARKER_NAME).is_file(): say("no addons to remove") return 0 - if dest_root.exists() and not (dest_root / MARKER_NAME).is_file() and any(dest_root.iterdir()): + if ( + dest_root.exists() + and not (dest_root / MARKER_NAME).is_file() + and any(dest_root.iterdir()) + and not hooks_owned(dest_root) + ): die(f"refusing to overwrite non-empty {dest_root} (not an HStack addon install)") state = read_state(state_path) if state_path.is_file() else {"addons": [], "created_config": False} if uninstall_all: diff --git a/scripts/apply_hooks.py b/scripts/apply_hooks.py new file mode 100644 index 0000000..7b32c30 --- /dev/null +++ b/scripts/apply_hooks.py @@ -0,0 +1,484 @@ +#!/usr/bin/env python3 +"""Install or remove optional Hermes shell hooks. + +Default installs do not call this. install.sh does, and only with +--with hermes-hooks or a named subset. The shims live under +/hstack/hooks and are registered in config.yaml under hooks:. +A backup is taken before a config change. A second run that would +write the same text does not write again. +""" + +from __future__ import annotations + +import argparse +import json +import shutil +import sys +from datetime import datetime, timezone +from pathlib import Path + + +HOOKS = ("guard", "save", "suggest") +ALIASES = { + "hermes-hooks": HOOKS, + "hermes-hooks-guard": ("guard",), + "hermes-hooks-save": ("save",), + "hermes-hooks-suggest": ("suggest",), +} +EVENTS = { + "guard": "pre_tool_call", + "save": "on_session_finalize", + "suggest": "pre_llm_call", +} +SCRIPTS = { + "guard": "guard.py", + "save": "save.py", + "suggest": "suggest.py", +} +MARKER = ".hstack-hooks" +STATE_NAME = "hooks.json" + + +def say(message: str) -> None: + print(f"hstack: {message}") + + +def die(message: str) -> None: + print(f"hstack: {message}", file=sys.stderr) + raise SystemExit(1) + + +def parse_with(raw: str) -> list[str]: + names = [part.strip() for part in raw.split(",")] if raw else [] + if any(not name for name in names): + die("empty hook name") + chosen: list[str] = [] + for name in names: + if name not in ALIASES: + die(f"unknown hook: {name}") + for hook in ALIASES[name]: + if hook not in chosen: + chosen.append(hook) + return chosen + + +def command_home(home: str) -> str: + return home.replace("\\", "/").rstrip("/") + + +def hook_command(home: str, hook: str) -> str: + return f"{command_home(home)}/hstack/hooks/{SCRIPTS[hook]}" + + +def yaml_quote(value: str) -> str: + escaped = value.replace("\\", "\\\\").replace('"', '\\"') + return f'"{escaped}"' + + +def entry_lines(home: str, hook: str) -> list[str]: + command = yaml_quote(hook_command(home, hook)) + lines = [f" # hstack:{hook}"] + if hook == "guard": + lines.append(' - matcher: "terminal"') + lines.append(f" command: {command}") + lines.append(" timeout: 5") + lines.append(" fail_closed: true") + else: + lines.append(f" - command: {command}") + lines.append(" timeout: 5") + return lines + + +def top_level_name(line: str) -> str | None: + if not line or line[0] in " \t#": + return None + head = line.split("#", 1)[0].rstrip() + if ":" not in head: + return None + name, _rest = head.split(":", 1) + if not name or " " in name or "\t" in name: + return None + return name + + +def find_block(lines: list[str], key: str) -> tuple[int, int] | None: + start = None + for index, line in enumerate(lines): + if top_level_name(line) == key: + if start is not None: + die(f"config.yaml has more than one {key} key") + start = index + if start is None: + return None + end = len(lines) + for index in range(start + 1, len(lines)): + if top_level_name(lines[index]): + end = index + break + return start, end + + +def normalize(text: str) -> str: + if "\t" in text: + die("refusing config.yaml that contains a tab") + return text.replace("\r\n", "\n").replace("\r", "\n") + + +def event_key(line: str) -> str | None: + if not line.startswith(" ") or line.startswith(" ") or line.startswith(" #"): + return None + head = line.split("#", 1)[0].rstrip() + if not head.endswith(":") or " " in head.strip(): + return None + name = head.strip()[:-1] + if not name: + return None + return name + + +def marker_at(line: str, hook: str) -> bool: + return line.strip() == f"# hstack:{hook}" + + +def entry_span(lines: list[str], start: int) -> int: + """Return the line after the hook entry that begins at start.""" + index = start + 1 + seen_item = False + while index < len(lines): + line = lines[index] + if line.strip() == "": + index += 1 + continue + if marker_at(line, "guard") or marker_at(line, "save") or marker_at(line, "suggest"): + break + if line.startswith(" - "): + if seen_item: + break + seen_item = True + index += 1 + continue + if line.startswith(" "): + index += 1 + continue + break + while index > start + 1 and lines[index - 1].strip() == "": + index -= 1 + return index + + +def find_marker(lines: list[str], start: int, end: int, hook: str) -> int | None: + for index in range(start, end): + if marker_at(lines[index], hook): + return index + return None + + +def ensure_nested(lines: list[str], start: int) -> None: + rest = lines[start].split(":", 1)[1].split("#", 1)[0].strip() + if rest: + die("refusing hooks because the header is not a nested mapping") + + +def event_bounds(lines: list[str], start: int, end: int, event: str) -> tuple[int, int] | None: + for index in range(start + 1, end): + if event_key(lines[index]) != event: + continue + nxt = index + 1 + while nxt < end and event_key(lines[nxt]) is None: + nxt += 1 + return index, nxt + return None + + +def drop_empty_events(lines: list[str]) -> None: + located = find_block(lines, "hooks") + if located is None: + return + start, end = located + empty = [] + index = start + 1 + while index < end: + if event_key(lines[index]) is None: + index += 1 + continue + nxt = index + 1 + while nxt < end and event_key(lines[nxt]) is None: + nxt += 1 + if not any(line.strip() for line in lines[index + 1 : nxt]): + empty.append((index, nxt)) + index = nxt + for begin, stop in reversed(empty): + del lines[begin:stop] + end -= stop - begin + located = find_block(lines, "hooks") + if located is None: + return + start, end = located + if not any(line.strip() for line in lines[start + 1 : end]): + del lines[start:end] + + +def render(text: str, home: str, hooks: list[str], remove: bool) -> str: + text = normalize(text) + if text and not text.endswith("\n"): + text += "\n" + lines = text.splitlines() + located = find_block(lines, "hooks") + if located is None: + if remove or not hooks: + return text + block = ["hooks:"] + for hook in hooks: + block.append(f" {EVENTS[hook]}:") + block.extend(entry_lines(home, hook)) + return text + "\n".join(block) + "\n" + start, end = located + ensure_nested(lines, start) + for hook in hooks: + located = find_block(lines, "hooks") + if located is None: + if remove: + break + lines.extend(["hooks:"]) + located = find_block(lines, "hooks") + if located is None: + break + start, end = located + marker = find_marker(lines, start, end, hook) + if remove: + if marker is None: + continue + stop = entry_span(lines, marker) + del lines[marker:stop] + continue + fresh = entry_lines(home, hook) + if marker is not None: + stop = entry_span(lines, marker) + if lines[marker:stop] == fresh: + continue + lines[marker:stop] = fresh + continue + event = EVENTS[hook] + located = find_block(lines, "hooks") + if located is None: + break + start, end = located + bounds = event_bounds(lines, start, end, event) + if bounds is None: + lines[end:end] = [f" {event}:", *fresh] + continue + _event_at, insert_at = bounds + lines[insert_at:insert_at] = fresh + if remove: + drop_empty_events(lines) + joined = "\n".join(lines).strip("\n") + if joined: + joined += "\n" + return joined + + +def read_state(path: Path) -> dict: + if not path.is_file(): + return {"hooks": [], "created_config": False} + try: + data = json.loads(path.read_text(encoding="utf-8")) + except json.JSONDecodeError as exc: + die(f"cannot read hook state: {exc}") + hooks = data.get("hooks") + if not isinstance(hooks, list) or any(item not in HOOKS for item in hooks): + die("hook state is invalid") + return {"hooks": list(hooks), "created_config": bool(data.get("created_config"))} + + +def write_state(path: Path, state: dict) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(state, indent=2) + "\n", encoding="utf-8") + + +def source_hooks(source: Path) -> Path: + direct = source / "hooks" + if (direct / "guard.py").is_file(): + return direct + nested = source / "hooks" + die(f"missing hooks in {source}") + return nested + + +def copy_hooks(source: Path, dest: Path, hooks: list[str], dry_run: bool) -> None: + origin = source_hooks(source) + names = ["hstack_hook.py", *[SCRIPTS[hook] for hook in hooks]] + if dry_run: + for name in names: + say(f"dry-run: would copy hooks/{name} to {dest / name}") + return + if dest.is_symlink(): + die(f"refusing to follow symlink {dest}") + dest.mkdir(parents=True, exist_ok=True) + for name in names: + src = origin / name + if not src.is_file(): + die(f"missing hook file {src}") + target = dest / name + shutil.copyfile(src, target) + target.chmod(0o755) + + +def copy_self(dest_root: Path, dry_run: bool) -> None: + target = dest_root / "apply_hooks.py" + if dry_run: + say(f"dry-run: would copy apply_hooks.py to {target}") + return + shutil.copyfile(Path(__file__).resolve(), target) + target.chmod(0o644) + + +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.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(config, dest) + say(f"backed up config.yaml to {dest}") + + +def remove_scripts(dest: Path, hooks: list[str], dry_run: bool) -> None: + names = [SCRIPTS[hook] for hook in hooks] + if not dest.exists(): + return + for name in names: + target = dest / name + if dry_run: + say(f"dry-run: would remove {target}") + continue + if target.is_file() and not target.is_symlink(): + target.unlink() + if dry_run: + return + remaining = [hook for hook in HOOKS if (dest / SCRIPTS[hook]).exists()] + if not remaining: + shared = dest / "hstack_hook.py" + if shared.is_file() and not shared.is_symlink(): + shared.unlink() + if dest.is_dir() and not any(dest.iterdir()): + dest.rmdir() + + +def prune_root(root: Path) -> None: + if not root.is_dir(): + return + backups = root / "backups" + if backups.is_dir() and not any(backups.iterdir()): + backups.rmdir() + if root.is_dir() and not any(root.iterdir()): + root.rmdir() + + +def apply(root: Path, source: Path | None, hooks: list[str], remove: bool, dry_run: bool, command_home: str | None = None) -> None: + if root.is_symlink(): + die(f"refusing to follow symlink {root}") + home = command_home or root.as_posix() + dest_root = root / "hstack" + dest_hooks = dest_root / "hooks" + state_path = dest_root / STATE_NAME + marker = dest_root / MARKER + config = root / "config.yaml" + state = read_state(state_path) if state_path.is_file() else {"hooks": [], "created_config": False} + targets = hooks + if remove and not targets: + targets = list(state["hooks"]) or list(HOOKS) + if not targets and not remove: + return + if remove and dest_root.exists() and not marker.is_file() and not state_path.is_file() and not config.is_file(): + say("no hooks to remove") + return + if not remove: + if source is None: + die("missing --source") + copy_hooks(source, dest_hooks, targets, dry_run) + copy_self(dest_root, dry_run) + text = config.read_text(encoding="utf-8") if config.is_file() else "" + updated = render(text, home, targets, remove) + if dry_run: + if updated != (text if text.endswith("\n") or text == "" else text + "\n") and not (updated == text): + verb = "remove" if remove else "write" + say(f"dry-run: would {verb} hooks {', '.join(targets)}") + elif updated == text or (text == "" and updated.strip() == ""): + say(f"dry-run: hooks unchanged ({', '.join(targets)})") + else: + say(f"dry-run: would {'remove' if remove else 'write'} hooks {', '.join(targets)}") + return + changed = updated != text and not (text == "" and updated == "") + if text and not text.endswith("\n") and updated == text + "\n": + changed = updated != text + if config.is_file() and updated != text: + backup_config(config, root) + config.write_text(updated, encoding="utf-8") + say(("removed" if remove else "wrote") + " hooks " + ", ".join(targets)) + elif not config.is_file() and updated.strip(): + config.parent.mkdir(parents=True, exist_ok=True) + config.write_text(updated, encoding="utf-8") + state["created_config"] = True + say("wrote hooks " + ", ".join(targets)) + else: + say("hooks unchanged (" + ", ".join(targets) + ")") + if remove: + remove_scripts(dest_hooks, targets, dry_run=False) + state["hooks"] = [hook for hook in state["hooks"] if hook not in targets] + if state["created_config"] and config.is_file() and not config.read_text(encoding="utf-8").strip(): + config.unlink() + state["created_config"] = False + say(f"removed empty {config}") + if state["hooks"]: + write_state(state_path, state) + else: + if state_path.is_file(): + state_path.unlink() + if marker.is_file() and not marker.is_symlink(): + marker.unlink() + 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(): + dest_root.mkdir(parents=True, exist_ok=True) + marker.write_text("managed-by: hstack install.sh\n", encoding="utf-8") + state["hooks"] = list(dict.fromkeys([*state["hooks"], *targets])) + write_state(state_path, state) + + +def main() -> int: + parser = argparse.ArgumentParser(prog="apply_hooks") + parser.add_argument("--hermes-home") + parser.add_argument("--source") + parser.add_argument("--with", dest="with_names", default="") + parser.add_argument("--command-home", default="") + parser.add_argument("--dry-run", action="store_true") + parser.add_argument("--uninstall", action="store_true") + parser.add_argument("--validate", action="store_true") + args = parser.parse_args() + if args.validate: + parse_with(args.with_names or "hermes-hooks") + return 0 + if not args.hermes_home: + die("missing --hermes-home") + requested = parse_with(args.with_names) if args.with_names else [] + if args.uninstall and not requested: + requested = [] + apply( + Path(args.hermes_home), + Path(args.source) if args.source else None, + requested, + remove=args.uninstall, + dry_run=args.dry_run, + command_home=args.command_home or None, + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/check_consent.py b/scripts/check_consent.py new file mode 100644 index 0000000..a8d7bae --- /dev/null +++ b/scripts/check_consent.py @@ -0,0 +1,135 @@ +#!/usr/bin/env python3 +"""Fail if HStack loads or installs an external stack without a confirm step.""" + +from __future__ import annotations + +import sys +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +SKILLS = ROOT / "skills" / "hstack" +STACKS = SKILLS / "hstack" / "references" / "stacks.md" +OWNED = { + "hstack", + "reframe", + "shape", + "prove", + "council", + "ship", + "takeover", + "modernize", + "blast", + "swarm", + "arena", + "principles", + "systematic-debugging", + "test-driven-development", + "requesting-code-review", + "dogfood", + "spike", + "simplify-code", + "hermes-agent-skill-authoring", + "github", + "humanizer", + "architecture-diagram", + "design-md", + "pdf", +} +URLS = ( + "codeload.github.com", + "github.com/garrytan/gstack", + "github.com/garrytan/gbrain", + "github.com/t3dotgg/tstack", + "github.com/cursor/plugins", +) +INSTALL = "hstack_stack.py install" + +ERRORS: list[str] = [] + + +def fail(message: str) -> None: + ERRORS.append(message) + + +def skill_views(text: str) -> list[str]: + names = [] + needle = 'skill_view("' + start = 0 + while True: + index = text.find(needle, start) + if index < 0: + break + end = text.find('"', index + len(needle)) + if end < 0: + break + names.append(text[index + len(needle) : end]) + start = end + 1 + return names + + +def check_tree() -> None: + if not STACKS.is_file(): + fail("missing stacks registry") + return + registry = STACKS.read_text(encoding="utf-8") + yes_at = registry.find("until the user says yes") + install_at = registry.find(INSTALL) + if yes_at < 0 or install_at < 0 or yes_at > install_at: + fail("stacks.md must require a yes before hstack_stack.py install") + for phrase in ("at most one", "decline", "--yes", "detect"): + if phrase not in registry: + fail(f"stacks.md is missing {phrase}") + for name in skill_views(registry): + if name not in OWNED: + fail(f"stacks.md loads {name}") + + for path in SKILLS.rglob("*.md"): + if path.resolve() == STACKS.resolve(): + continue + text = path.read_text(encoding="utf-8") + for url in URLS: + if url in text: + fail(f"{path.relative_to(ROOT)} contains {url}") + if INSTALL in text: + fail(f"{path.relative_to(ROOT)} installs a stack") + for name in skill_views(text): + if name not in OWNED: + fail(f"{path.relative_to(ROOT)} loads {name}") + + for path in (ROOT / "hooks").glob("*.py"): + text = path.read_text(encoding="utf-8") + for url in URLS: + if url in text: + fail(f"{path.relative_to(ROOT)} contains {url}") + if INSTALL in text: + fail(f"{path.relative_to(ROOT)} installs a stack") + + +def check_fixtures() -> None: + sneaky = 'Load it with skill_view("gstack-ship").' + if any(name not in OWNED for name in skill_views(sneaky)): + pass + else: + fail("fixture did not catch an outside skill_view") + bare = "Run hstack_stack.py install gstack --yes now." + if "until the user says yes" in bare: + fail("fixture install unexpectedly contains a yes") + confirmed = "Do not download until the user says yes.\nThen hstack_stack.py install --yes.\n" + if confirmed.find("until the user says yes") > confirmed.find(INSTALL): + fail("ordered confirm fixture was rejected") + + +def main() -> int: + check_fixtures() + check_tree() + if ERRORS: + for message in ERRORS: + print(f"error: {message}", file=sys.stderr) + return 1 + print("check_consent: ok") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/check_hooks.py b/scripts/check_hooks.py new file mode 100644 index 0000000..d9d858d --- /dev/null +++ b/scripts/check_hooks.py @@ -0,0 +1,203 @@ +#!/usr/bin/env python3 +"""Hooks install, uninstall, and idempotency on linux, macOS, and Windows paths.""" + +from __future__ import annotations + +import json +import os +import subprocess +import sys +import tempfile +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "scripts")) +sys.path.insert(0, str(ROOT / "hooks")) +import apply_hooks # noqa: E402 +import hstack_hook # noqa: E402 + + +def fail(message: str) -> None: + print(f"check_hooks: {message}", file=sys.stderr) + raise SystemExit(1) + + +def config_text(home: str) -> str: + foreign = """model: local +hooks: + pre_tool_call: + - matcher: "write_file" + command: "/usr/bin/other" + post_tool_call: + - command: "/usr/bin/log" +mcp_servers: + other: + url: "https://example.com/mcp" +""" + once = apply_hooks.render(foreign, home, ["guard", "save", "suggest"], remove=False) + twice = apply_hooks.render(once, home, ["guard", "save", "suggest"], remove=False) + if once != twice: + fail(f"second render changed {home}") + if once.count("# hstack:guard") != 1: + fail(f"guard was duplicated for {home}") + command = apply_hooks.hook_command(home, "guard") + if command not in once: + fail(f"guard command missing for {home}: {command}") + if "/usr/bin/other" not in once or "/usr/bin/log" not in once: + fail(f"foreign hook was dropped for {home}") + if "example.com" not in once: + fail(f"foreign server was dropped for {home}") + removed = apply_hooks.render(once, home, ["guard"], remove=True) + if "# hstack:guard" in removed or command in removed: + fail(f"guard survived uninstall for {home}") + if "# hstack:save" not in removed: + fail(f"uninstall guard removed save for {home}") + cleared = apply_hooks.render(removed, home, ["save", "suggest"], remove=True) + if "# hstack:" in cleared: + fail(f"hook marker survived full removal for {home}") + if "/usr/bin/other" not in cleared or "/usr/bin/log" not in cleared or "example.com" not in cleared: + fail(f"foreign config was damaged for {home}") + only = apply_hooks.render("", home, ["guard", "save", "suggest"], remove=False) + gone = apply_hooks.render(only, home, ["guard", "save", "suggest"], remove=True) + if "hooks:" in gone or gone.strip(): + fail(f"empty hooks block survived for {home}") + return once + + +def live(root: Path, command_home: str, hooks: list[str]) -> None: + source = ROOT + apply_hooks.apply(root, source, hooks, remove=False, dry_run=True, command_home=command_home) + if (root / "config.yaml").exists(): + 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 [] + # 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 [] + 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") + if again != config: + fail(f"idempotent install changed config for {command_home}") + for hook in hooks: + if again.count(f"# hstack:{hook}") != 1: + fail(f"{hook} was not singular for {command_home}") + if apply_hooks.hook_command(command_home, hook) not in again: + fail(f"{hook} command path mismatch for {command_home}") + if not (root / "hstack" / "hooks" / apply_hooks.SCRIPTS[hook]).is_file(): + fail(f"missing shim {hook} for {command_home}") + apply_hooks.apply(root, source, hooks, remove=True, dry_run=False, command_home=command_home) + if (root / "config.yaml").exists() and (root / "config.yaml").read_text(encoding="utf-8").strip(): + fail(f"uninstall left config for {command_home}") + if (root / "hstack" / "hooks").exists(): + fail(f"uninstall left hook scripts 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.*")) + 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: + 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: + fail("guard install damaged a foreign hook") + apply_hooks.apply(root, ROOT, ["guard"], remove=True, dry_run=False, command_home=root.as_posix()) + left = (root / "config.yaml").read_text(encoding="utf-8") + if "# hstack:" in left or "hstack/hooks" in left: + fail("guard uninstall left its entry") + if "/usr/bin/log" not in left: + fail("guard uninstall removed the foreign hook") + + +def installer() -> None: + with tempfile.TemporaryDirectory() as tmp: + home = Path(tmp) / "linux" + env = {**os.environ, "HERMES_HOME": str(home)} + subprocess.run(["sh", str(ROOT / "install.sh")], cwd=ROOT, env=env, check=True) + if (home / "config.yaml").exists(): + fail("default install wrote config.yaml") + if (home / "hstack" / "hooks").exists(): + fail("default install wrote hooks") + subprocess.run(["sh", str(ROOT / "install.sh"), "--with", "hermes-hooks"], cwd=ROOT, env=env, check=True) + text = (home / "config.yaml").read_text(encoding="utf-8") + for hook in ("guard", "save", "suggest"): + if text.count(f"# hstack:{hook}") != 1: + fail(f"installer missing {hook}") + before = text + subprocess.run(["sh", str(ROOT / "install.sh"), "--with", "hermes-hooks"], cwd=ROOT, env=env, check=True) + if (home / "config.yaml").read_text(encoding="utf-8") != before: + fail("installer was not idempotent") + subprocess.run(["sh", str(ROOT / "install.sh"), "--uninstall", "--with", "hermes-hooks-guard"], cwd=ROOT, env=env, check=True) + left = (home / "config.yaml").read_text(encoding="utf-8") + if "# hstack:guard" in left or "hooks/guard.py" in left: + fail("subset uninstall left the guard") + if "# hstack:save" not in left or "# hstack:suggest" not in left: + fail("subset uninstall removed another hook") + if not (home / "skills" / "hstack" / "hstack" / "SKILL.md").is_file(): + fail("hook uninstall removed skills") + subprocess.run(["sh", str(ROOT / "install.sh"), "--with", "browser,hermes-hooks-guard"], cwd=ROOT, env=env, check=True) + both = (home / "config.yaml").read_text(encoding="utf-8") + if "hstack_playwright" not in both or "# hstack:guard" not in both: + fail("combined addon and hook install missed one") + subprocess.run(["sh", str(ROOT / "install.sh"), "--uninstall"], cwd=ROOT, env=env, check=True) + if (home / "skills" / "hstack").exists(): + fail("full uninstall left skills") + if (home / "config.yaml").exists(): + fail("full uninstall left a config this installer created") + state = home / "hstack" / "hooks.json" + if state.exists(): + fail(f"hook state remained: {state.read_text()}") + + +def behavior() -> None: + blocked = hstack_hook.guard(json.dumps({"tool_name": "terminal", "tool_input": {"command": "rm -rf /"}})) + if blocked != 0: + fail("guard should block with JSON, not a crash") + allowed = hstack_hook.guard(json.dumps({"tool_name": "terminal", "tool_input": {"command": "rm -rf ./build"}})) + if allowed != 0: + fail("guard blocked a local rm") + home = Path(tempfile.mkdtemp()) + os.environ["HERMES_HOME"] = str(home) + hstack_hook.save(json.dumps({"session_id": "abc", "extra": {"platform": "cli", "user_message": "secret-token"}})) + log = (home / "hstack" / "session-log.tsv").read_text(encoding="utf-8") + if "abc" not in log or "secret-token" in log: + fail("session log saved the wrong fields") + injected = hstack_hook.suggest( + json.dumps({"extra": {"is_first_turn": True, "user_message": "the export is broken"}}) + ) + if injected != 0: + fail("suggest failed") + later = hstack_hook.suggest( + json.dumps({"extra": {"is_first_turn": False, "user_message": "the export is broken"}}) + ) + if later != 0: + fail("suggest failed on a later turn") + + +def main() -> int: + for home in ("/home/dev/.hermes", "/Users/dev/.hermes", "C:/Users/dev/.hermes"): + config_text(home) + with tempfile.TemporaryDirectory() as tmp: + base = Path(tmp) + live(base / "linux", "/home/dev/.hermes", ["guard", "save", "suggest"]) + live(base / "macos", "/Users/dev/.hermes", ["guard", "save", "suggest"]) + live(base / "windows", "C:/Users/dev/.hermes", ["guard", "save", "suggest"]) + foreign_backup(base / "foreign") + installer() + behavior() + print("check_hooks: linux, macos-sim, and windows paths ok") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/check_piped_install.sh b/scripts/check_piped_install.sh index 3d1f8f0..f60560a 100755 --- a/scripts/check_piped_install.sh +++ b/scripts/check_piped_install.sh @@ -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.1.0' "$home/hermes/skills/hstack/.hstack-managed" || fail "$label marker version" + grep -q 'version: 0.2.0' "$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 @@ -129,6 +129,8 @@ install_with macos "$MAC:/bin" HOME=$TMP/pin HERMES_HOME=$TMP/pin/hermes HSTACK_BASE_URL="http://127.0.0.1:$PORT" HSTACK_VERSION=0.1.0 \ /bin/sh -c 'curl -fsSL "$HSTACK_BASE_URL/install" | sh' test -f "$TMP/pin/hermes/skills/hstack/shape/SKILL.md" || fail "version pin did not install" +grep -q 'version: 0.1.0' "$TMP/pin/hermes/skills/hstack/.hstack-managed" || fail "version pin marker is not 0.1.0" +test ! -e "$TMP/pin/hermes/skills/hstack/hstack/references/stacks.md" || fail "version pin installed the 0.2 registry" mkdir -p "$TMP/bad/dl" cp "$SITE/dist/dl/hstack.tar.gz" "$TMP/bad/dl/hstack.tar.gz" diff --git a/scripts/check_skills.py b/scripts/check_skills.py index ae73424..a6c7ab8 100755 --- a/scripts/check_skills.py +++ b/scripts/check_skills.py @@ -105,6 +105,9 @@ ROUTES_FILE = "routes.md" ROUTES_CEILING = 500 +STACKS_FILE = "stacks.md" +STACKS_CEILING = 1400 +ROUTER_SCRIPT = "scripts/hstack_stack.py" # Frozen at hermes-agent ee7e0f517232fb619c72e49d7e648436bddc598e. BUNDLED = { @@ -288,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.1.0": - fail(f"{name}: version must be 0.1.0") + if meta.get("version") != "0.2.0": + fail(f"{name}: version must be 0.2.0") if meta.get("license") != "MIT": fail(f"{name}: license must be MIT") if meta.get("author") != "Preetham Kyanam": @@ -333,7 +336,7 @@ def check_skills() -> dict[str, int]: def check_playbooks() -> dict[str, int]: ref = SKILL_ROOT / "hstack" / "references" - expected = sorted([*PLAYBOOKS, ROUTES_FILE]) + expected = sorted([*PLAYBOOKS, ROUTES_FILE, STACKS_FILE]) found = sorted(p.name for p in ref.glob("*.md")) if ref.is_dir() else [] if found != expected: fail(f"playbooks are {found}, expected {expected}") @@ -341,18 +344,19 @@ def check_playbooks() -> dict[str, int]: if other: fail(f"unexpected files in references: {other}") sizes: dict[str, int] = {} - ceilings = {**PLAYBOOKS, ROUTES_FILE: ROUTES_CEILING} + ceilings = {**PLAYBOOKS, ROUTES_FILE: ROUTES_CEILING, STACKS_FILE: STACKS_CEILING} for name, ceiling in ceilings.items(): text = (ref / name).read_text(encoding="utf-8") sizes[name] = estimate_tokens(text) if sizes[name] > ceiling: fail(f"{name}: playbook is {sizes[name]} tokens, ceiling is {ceiling}") - if name != ROUTES_FILE and sizes[name] > 500: + if name not in (ROUTES_FILE, STACKS_FILE) and sizes[name] > 500: fail(f"{name}: playbook exceeds the 500 token cap") blob = text.lower() - for banned in BANNED: - if banned in blob: - fail(f"{name}: playbook contains {banned}") + if name != STACKS_FILE: + for banned in BANNED: + if banned in blob: + fail(f"{name}: playbook contains {banned}") prose = re.sub(r"```.*?```", "", text, flags=re.DOTALL) for util in SHELL_UTILS: if f"`{util}`" in prose: @@ -360,7 +364,12 @@ def check_playbooks() -> dict[str, int]: stray = [ p.relative_to(SKILL_ROOT / "hstack").as_posix() for p in (SKILL_ROOT / "hstack").rglob("*") - if p.is_file() and p.name != "SKILL.md" and p.parent.name != "references" + if p.is_file() + and p.suffix != ".pyc" + and "__pycache__" not in p.parts + and p.name != "SKILL.md" + and p.parent.name != "references" + and p.relative_to(SKILL_ROOT / "hstack").as_posix() != ROUTER_SCRIPT ] if stray: fail(f"unexpected files beside the router: {stray}") @@ -422,8 +431,12 @@ def named_views(text: str) -> list[str]: def route_upper_bound(route: str, bodies: dict[str, int], routes_tokens: int) -> int: + router = (SKILL_ROOT / "hstack" / "SKILL.md").read_text(encoding="utf-8") + if "references/stacks.md" not in router: + fail("router must load references/stacks.md after the playbook") + stacks = (SKILL_ROOT / "hstack" / "references" / STACKS_FILE).read_text(encoding="utf-8") playbook = (SKILL_ROOT / "hstack" / "references" / f"{route}.md").read_text(encoding="utf-8") - total = bodies["hstack"] + routes_tokens + estimate_tokens(playbook) + total = bodies["hstack"] + routes_tokens + estimate_tokens(playbook) + estimate_tokens(stacks) seen = {"hstack"} for name in named_views(playbook): if name in seen: diff --git a/scripts/check_stacks.py b/scripts/check_stacks.py new file mode 100644 index 0000000..71e529d --- /dev/null +++ b/scripts/check_stacks.py @@ -0,0 +1,208 @@ +#!/usr/bin/env python3 +"""External stack detection, decline, and the sha256 gate. No real download.""" + +from __future__ import annotations + +import hashlib +import http.server +import json +import socketserver +import subprocess +import tarfile +import tempfile +import threading +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +SCRIPT = ROOT / "skills" / "hstack" / "hstack" / "scripts" / "hstack_stack.py" + + +def fail(message: str) -> None: + print(f"check_stacks: {message}", file=sys.stderr) + raise SystemExit(1) + + +import sys # noqa: E402 + + +def run(args: list[str]) -> subprocess.CompletedProcess[str]: + return subprocess.run([sys.executable, str(SCRIPT), *args], text=True, capture_output=True) + + +def registry(url: str, digest: str, directory: Path) -> Path: + directory.mkdir(parents=True, exist_ok=True) + path = directory / "stacks.md" + payload = { + "stacks": [ + { + "id": "gstack", + "good_at": "test", + "license": "MIT", + "commit": "9e3308f65adf547b7e4776e5ed22467a2649d2ee", + "sha256": digest, + "archive": url, + }, + { + "id": "pstack", + "good_at": "test", + "license": "MIT", + "commit": "c742dd61706fbacc1b95f1a20b7b988cf096ce6c", + "sha256": "0" * 64, + "archive": url, + }, + { + "id": "tstack", + "good_at": "test", + "license": "MIT", + "commit": "1085050f3baed1ac724caa4a8166145fb2ac88af", + "sha256": "0" * 64, + "archive": url, + }, + { + "id": "gbrain", + "good_at": "test", + "license": "MIT", + "commit": "82b7ef601fdfc449b0d242f7e61a4604aa2c8a8e", + "sha256": "0" * 64, + "archive": url, + }, + ] + } + path.write_text("```json\n" + json.dumps(payload) + "\n```\n", encoding="utf-8") + return path + + +def make_archive(directory: Path) -> tuple[Path, str]: + top = directory / "payload" + (top / "readme").parent.mkdir(parents=True) + (top / "readme").write_text("not a skill\n", encoding="utf-8") + archive = directory / "stack.tar.gz" + with tarfile.open(archive, "w:gz") as handle: + handle.add(top, arcname="upstream") + digest = hashlib.sha256(archive.read_bytes()).hexdigest() + return archive, digest + + +def detect_cases(tmp: Path) -> None: + home = tmp / "home" + hermes = tmp / "hermes" + marker = home / ".claude" / "skills" / "gstack" / "bin" / "gstack-state-root.sh" + marker.parent.mkdir(parents=True) + marker.write_text("#!/bin/sh\ntouch \"$0.ran\"\n", encoding="utf-8") + marker.chmod(0o755) + reg = registry("https://example.invalid/nope.tar.gz", "ab" * 32, tmp) + result = run(["detect", "--session", "chat-1", "--home", str(home), "--hermes-home", str(hermes), "--registry", str(reg)]) + if result.returncode != 0: + fail(result.stderr) + if "id=gstack installed=yes" not in result.stdout: + fail("gstack marker was not detected") + if marker.with_name("gstack-state-root.sh.ran").exists(): + fail("detection executed gstack") + if "id=pstack installed=no" not in result.stdout: + fail("pstack should be absent") + poteto = home / ".cursor" / "plugins" / "cache" / "pstack" / "skills" / "poteto-mode" / "SKILL.md" + poteto.parent.mkdir(parents=True) + poteto.write_text("---\nname: poteto-mode\n---\n", encoding="utf-8") + result = run(["detect", "--session", "chat-1", "--home", str(home), "--hermes-home", str(hermes), "--registry", str(reg)]) + if "id=pstack installed=yes" not in result.stdout: + fail("pstack skill file was not detected") + skills = home / ".claude" / "skills" + (skills / "leaving-pr-comments").mkdir() + (skills / "leaving-pr-comments" / "SKILL.md").write_text("x\n", encoding="utf-8") + (skills / "sol-loop").mkdir() + (skills / "sol-loop" / "SKILL.md").write_text("x\n", encoding="utf-8") + result = run(["detect", "--home", str(home), "--hermes-home", str(hermes), "--registry", str(reg)]) + if "id=tstack installed=yes" not in result.stdout: + fail("tstack pair was not detected") + (home / ".gbrain").mkdir() + (home / ".gbrain" / "config.json").write_text("{}\n", encoding="utf-8") + result = run(["detect", "--home", str(home), "--hermes-home", str(hermes), "--registry", str(reg)]) + if "id=gbrain installed=yes" not in result.stdout: + fail("gbrain config was not detected") + state_only = tmp / "state-only" + (state_only / ".gstack").mkdir(parents=True) + (state_only / ".gstack" / "config.yaml").write_text("telemetry: off\n", encoding="utf-8") + result = run(["detect", "--home", str(state_only), "--hermes-home", str(tmp / "empty-hermes"), "--registry", str(reg)]) + if "id=gstack installed=yes" in result.stdout: + fail("gstack state directory counted as an install") + + +def install_cases(tmp: Path) -> None: + archive, digest = make_archive(tmp) + hits = {"n": 0} + + class Handler(http.server.BaseHTTPRequestHandler): + def do_GET(self): # noqa: N802 + hits["n"] += 1 + body = archive.read_bytes() + self.send_response(200) + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, fmt: str, *args) -> None: + return + + server = socketserver.TCPServer(("127.0.0.1", 0), Handler) + port = server.server_address[1] + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + url = f"http://127.0.0.1:{port}/stack.tar.gz" + hermes = tmp / "install-home" + reg = registry(url, digest, tmp) + refused = run(["install", "gstack", "--session", "chat-1", "--hermes-home", str(hermes), "--registry", str(reg)]) + if refused.returncode == 0 or "without --yes" not in refused.stderr: + fail(f"install without yes did not refuse: {refused.stderr}") + if hits["n"] != 0: + fail("install without yes downloaded") + bad = registry(url, "a" * 64, tmp / "bad") + mismatched = run( + ["install", "gstack", "--yes", "--session", "chat-1", "--hermes-home", str(hermes), "--registry", str(bad)] + ) + if mismatched.returncode == 0 or "checksum mismatch" not in mismatched.stderr: + fail(f"bad checksum was not rejected: {mismatched.stderr}") + if (hermes / "hstack" / "external").exists(): + fail("bad checksum extracted") + good = run( + ["install", "gstack", "--yes", "--session", "chat-1", "--hermes-home", str(hermes), "--registry", str(reg)] + ) + if good.returncode != 0: + fail(good.stderr) + receipt = hermes / "hstack" / "external" / "gstack" / "9e3308f65adf547b7e4776e5ed22467a2649d2ee" + if not (receipt / ".hstack-external").is_file() or not (receipt / "readme").is_file(): + fail("extract did not land outside the skill tree") + if (hermes / "skills").exists(): + fail("install wrote a skill tree") + before = hits["n"] + again = run( + ["install", "gstack", "--yes", "--session", "chat-1", "--hermes-home", str(hermes), "--registry", str(reg)] + ) + if again.returncode != 0 or "already installed" not in again.stdout: + fail("second install was not idempotent") + if hits["n"] != before: + fail("idempotent install downloaded again") + declined = run(["decline", "pstack", "--session", "chat-1", "--hermes-home", str(hermes), "--registry", str(reg)]) + if declined.returncode != 0: + fail(declined.stderr) + blocked = run( + ["install", "pstack", "--yes", "--session", "chat-1", "--hermes-home", str(hermes), "--registry", str(reg)] + ) + if blocked.returncode == 0 or "declined" not in blocked.stderr: + fail("declined install was not refused") + finally: + server.shutdown() + + +def main() -> int: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + detect_cases(root / "detect") + install_cases(root / "install") + print("check_stacks: detect, decline, checksum, and idempotent install ok") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/site/DEPLOY.md b/site/DEPLOY.md index 06a52a5..12cb38d 100644 --- a/site/DEPLOY.md +++ b/site/DEPLOY.md @@ -37,10 +37,20 @@ Leave the Porkbun nameservers alone. The registrar stays Porkbun. Vercel does no - `https://hstack.sh/install.ps1` is the Windows installer, also `text/plain`. - 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. -- `https://hstack.sh/dl/hstack.tar.gz` is the latest archive. `hstack.tar.gz.sha256` is its digest. -- `https://hstack.sh/dl/hstack-v0.1.0.tar.gz` is that same release by version. The installer checks the digest before it extracts. -The archive has one top-level directory. Inside it are `skills/hstack/`, `scripts/apply_addons.py`, `bin/` (the addon helpers), and `VERSION`. +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`. + +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` | + +`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`. + +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`. ## Cloudflare Pages fallback diff --git a/site/_headers b/site/_headers index d2dfa54..bf35749 100644 --- a/site/_headers +++ b/site/_headers @@ -38,6 +38,14 @@ Content-Type: text/plain; charset=utf-8 Cache-Control: public, max-age=31536000, immutable +/dl/hstack-v0.2.0.tar.gz + Content-Type: application/gzip + Cache-Control: public, max-age=31536000, immutable + +/dl/hstack-v0.2.0.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 diff --git a/site/content/changelog.md b/site/content/changelog.md index 8d9beb7..ff4b575 100644 --- a/site/content/changelog.md +++ b/site/content/changelog.md @@ -1,8 +1,18 @@ --- title: Changelog -description: HStack 0.1.0. Twelve skills, an idle index of 204 tokens, optional add-ons, and a sha256-checked install from hstack.sh. +description: HStack 0.2.0. Optional Hermes hooks, a consent gate for outside stacks, and the same 204-token idle index. --- +## 0.2.0 + +28 October 2026. + +- Optional Hermes shell hooks, off unless you pass `--with hermes-hooks`. Subsets are `hermes-hooks-guard`, `hermes-hooks-save`, and `hermes-hooks-suggest`. The guard is `pre_tool_call` on `terminal` with `fail_closed`. The session stamp is `on_session_finalize`. The hint is `pre_llm_call` on the first turn and names an HStack playbook only. `pre_command` cannot block a tool, so it is not used. +- The router still picks one HStack playbook first, then reads `references/stacks.md`. It suggests at most one outside stack, and it loads nothing and downloads nothing until you say yes. A no lasts for the session. Install, when you ask, checks a pinned sha256 and extracts outside the skill tree. +- Pins, read on 28 October 2026: gstack `9e3308f65adf547b7e4776e5ed22467a2649d2ee`, pstack inside cursor/plugins `c742dd61706fbacc1b95f1a20b7b988cf096ce6c`, tstack `1085050f3baed1ac724caa4a8166145fb2ac88af`, gbrain `82b7ef601fdfc449b0d242f7e61a4604aa2c8a8e`. Detection reads files. It does not run those projects. +- Idle index stays 204. The planning path is 1,052 tokens. All `SKILL.md` bodies are 3,907. +- `HSTACK_VERSION=0.1.0` still fetches `hstack-v0.1.0.tar.gz`. The latest alias is 0.2.0. + ## 0.1.0 28 October 2026. diff --git a/site/content/docs/addons.md b/site/content/docs/addons.md index e113cf7..cbdad81 100644 --- a/site/content/docs/addons.md +++ b/site/content/docs/addons.md @@ -34,3 +34,15 @@ Helpers are copied to `$HERMES_HOME/hstack/bin`. MCP entries use an `hstack_` pr | `trail` | `hstack-decisions` | The browser server is `npx -y @playwright/mcp@0.0.83` under `mcp_servers.hstack_playwright`. Docs and search store a URL and no token: `https://mcp.context7.com/mcp` and `https://mcp.deepwiki.com/mcp`. + +## Hermes hooks + +Hooks are separate from the add-ons above. A default install adds none. `--with hermes-hooks` registers three shell hooks. Each shim is a few lines and calls `hstack_hook.py` under `$HERMES_HOME/hstack/hooks`. The skills do not import that module. + +| Flag | Event | What it does | +|---|---|---| +| `hermes-hooks-guard` | `pre_tool_call` matcher `terminal`, `fail_closed` | Blocks a fork bomb, a power command, `mkfs`, `dd` onto a disk device, and `rm -rf` of a filesystem root | +| `hermes-hooks-save` | `on_session_finalize` | Appends time, session id, and platform to `session-log.tsv`. It does not write the user message | +| `hermes-hooks-suggest` | `pre_llm_call`, first turn only | Adds one line naming an HStack playbook. It does not name another stack | + +`pre_command` cannot block, so the guard does not use it. The installer backs up `config.yaml` before a real change and leaves every other hook in place. It does not set `hooks_auto_accept` and it does not write Hermes's hook allowlist. A second run that would write the same text does not take another backup. `--uninstall --with hermes-hooks-guard` removes that entry only. diff --git a/site/content/docs/index.md b/site/content/docs/index.md index 90820ad..2837b9a 100644 --- a/site/content/docs/index.md +++ b/site/content/docs/index.md @@ -15,6 +15,6 @@ Skills land in `~/.hermes/skills/hstack/`. Set `HERMES_HOME` when Hermes lives s /ship ``` -The planning path is `hstack`, `reframe`, and `shape`: 965 tokens, under 2% of GStack's planning load on Hermes. The idle index is 204 tokens. All twelve `SKILL.md` bodies are 3,820. Counts use Hermes's estimator, `ceil(bytes / 4)`. +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. 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 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. diff --git a/site/content/docs/install.md b/site/content/docs/install.md index 33fc7d2..35664fd 100644 --- a/site/content/docs/install.md +++ b/site/content/docs/install.md @@ -41,7 +41,7 @@ powershell -ExecutionPolicy Bypass -File .\install.ps1 --dry-run ## Uninstall -Uninstall removes `skills/hstack` only when this installer created it, and it removes add-ons this installer created. `--uninstall --with browser` removes only that add-on. +Uninstall removes `skills/hstack` only when this installer created it, and it removes add-ons and hooks this installer created. `--uninstall --with browser` removes only that add-on. `--uninstall --with hermes-hooks` removes the hooks and leaves the skills. :::tabs :::tab macOS / Linux @@ -56,12 +56,12 @@ powershell -ExecutionPolicy Bypass -File .\install.ps1 --uninstall ## Version and host -`HSTACK_VERSION=0.1.0` pins `hstack-v0.1.0.tar.gz` instead of the latest alias. `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.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. ```sh install.sh curl -fsSL https://hstack.sh/install | HSTACK_VERSION=0.1.0 sh ``` -The archive is one directory. Inside it: the skills, `scripts/apply_addons.py`, 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.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. WSL can run `install.sh`. The Windows script takes the same flags and the same archive. diff --git a/site/content/integrations.md b/site/content/integrations.md index fd0c80f..3cc2fc4 100644 --- a/site/content/integrations.md +++ b/site/content/integrations.md @@ -1,6 +1,6 @@ --- title: Integrations -description: What HStack connects to Hermes. Skills are the default. Browser, GitHub, Context7, DeepWiki, and the helper scripts are opt-in. +description: What HStack connects to Hermes. Skills are the default. Hooks, browser, GitHub, Context7, DeepWiki, and the helper scripts are opt-in. --- Hermes already has a browser, a terminal, delegation, and a bundled GitHub skill. HStack does not replace those. Optional add-ons sit beside them and remove cleanly. @@ -24,4 +24,16 @@ Hermes already has a browser, a terminal, delegation, and a bundled GitHub skill `security` runs gitleaks or osv-scanner only when one of them is already on `PATH`. The installer does not fetch either binary. It also does not download Playwright. -Commands, dry-run, and the Windows `-File` form are on the [add-ons](/docs/addons) page. Upstream inventories are [GStack](https://github.com/garrytan/gstack), [TStack](https://github.com/t3dotgg/tstack), and [PStack](https://github.com/cursor/plugins/tree/main/pstack). Hermes itself is documented at [hermes-agent.nousresearch.com](https://hermes-agent.nousresearch.com/docs/). +Commands, dry-run, and the Windows `-File` form are on the [add-ons](/docs/addons) page. + +## 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. + +## Outside stacks + +HStack does not copy gstack, pstack, tstack, or gbrain into this repo, and it does not put them on `skills.external_dirs`. The registry is `skills/hstack/hstack/references/stacks.md`. The router reads it only after an HStack playbook. If one installed stack fits better, the reply is one line with the name and why, then a question. Nothing from that stack loads until the answer is yes. If it is not installed, the reply shows the install command and waits. Yes runs `hstack_stack.py install --yes`, which checks the pinned sha256 before extract. The files land in `$HERMES_HOME/hstack/external///`. No is remembered for the session. + +Detection does not execute the other project. gstack counts when a skill file is under the Hermes `skills/gstack` directory, or when `gstack-state-root.sh` is present under Claude or Codex skills. `~/.gstack` is state, not an install. pstack counts when `skills/poteto-mode/SKILL.md` is under `~/.cursor/plugins` or `~/.claude/plugins`. tstack counts when one skills directory holds both `leaving-pr-comments/SKILL.md` and `sol-loop/SKILL.md`. gbrain counts when `~/.gbrain/config.json` exists, when `GBRAIN_HOME` points at that config, or when `mcp_servers.gbrain` is already in the Hermes config. A `gbrain` binary on `PATH` is not enough. + +Upstream inventories are [GStack](https://github.com/garrytan/gstack), [TStack](https://github.com/t3dotgg/tstack), [PStack](https://github.com/cursor/plugins/tree/main/pstack), and [GBrain](https://github.com/garrytan/gbrain). Hermes itself is documented at [hermes-agent.nousresearch.com](https://hermes-agent.nousresearch.com/docs/). diff --git a/site/scripts/build.mjs b/site/scripts/build.mjs index ecde0a7..b66b05a 100644 --- a/site/scripts/build.mjs +++ b/site/scripts/build.mjs @@ -59,8 +59,8 @@ const version = skills[0].version; if (!skills.every((skill) => skill.version === version)) { throw new Error("skill versions differ"); } -if (version !== "0.1.0") { - throw new Error(`version is ${version}; dist publishes hstack-v0.1.0.tar.gz`); +if (version !== "0.2.0") { + throw new Error(`version is ${version}; dist publishes hstack-v0.2.0.tar.gz`); } const idle = tokens(" hstack:", "category") + skills.reduce((sum, skill) => sum + skill.idle, 0); @@ -69,7 +69,7 @@ const planning = ["hstack", "reframe", "shape"].reduce((sum, name) => { }, 0); const bodies = skills.reduce((sum, skill) => sum + skill.body, 0); -if (idle !== 204 || planning !== 965 || bodies !== 3820) { +if (idle !== 204 || planning !== 1052 || bodies !== 3907) { throw new Error(`measured idle ${idle}, planning ${planning}, bodies ${bodies}`); } @@ -99,8 +99,12 @@ try { mkdirSync(path.join(top, "skills"), { recursive: true }); mkdirSync(path.join(top, "scripts"), { recursive: true }); mkdirSync(path.join(top, "bin"), { recursive: true }); - cpSync(skillsRoot, path.join(top, "skills", "hstack"), { recursive: true }); + mkdirSync(path.join(top, "hooks"), { recursive: true }); + const skipCache = (src) => !src.split(path.sep).includes("__pycache__"); + cpSync(skillsRoot, path.join(top, "skills", "hstack"), { recursive: true, filter: skipCache }); + cpSync(path.join(root, "hooks"), path.join(top, "hooks"), { recursive: true, filter: skipCache }); cpSync(path.join(root, "scripts", "apply_addons.py"), path.join(top, "scripts", "apply_addons.py")); + cpSync(path.join(root, "scripts", "apply_hooks.py"), path.join(top, "scripts", "apply_hooks.py")); for (const helper of helpers) { cpSync(path.join(root, "bin", helper), path.join(top, "bin", helper)); } @@ -131,7 +135,14 @@ try { const required = [ "hstack/VERSION", "hstack/skills/hstack/hstack/SKILL.md", + "hstack/skills/hstack/hstack/references/stacks.md", + "hstack/skills/hstack/hstack/scripts/hstack_stack.py", "hstack/scripts/apply_addons.py", + "hstack/scripts/apply_hooks.py", + "hstack/hooks/guard.py", + "hstack/hooks/save.py", + "hstack/hooks/suggest.py", + "hstack/hooks/hstack_hook.py", ...helpers.map((helper) => `hstack/bin/${helper}`), ]; for (const name of required) { @@ -151,9 +162,23 @@ try { throw new Error(`archive helper is not executable: ${helper}`); } } + const frozenVersion = "0.1.0"; + const frozenSha = "0c8009ee04fb31126b76e4202c91eee14e8d5be7d1cd42ac0cee551012eeda6e"; + const vendorArchive = path.join(site, "vendor", "dl", `hstack-v${frozenVersion}.tar.gz`); + const frozenBytes = readFileSync(vendorArchive); + const frozenDigest = crypto.createHash("sha256").update(frozenBytes).digest("hex"); + if (frozenDigest !== frozenSha) { + throw new Error(`vendored hstack-v${frozenVersion}.tar.gz hash is ${frozenDigest}`); + } + const frozenTarget = path.join(dist, "dl", `hstack-v${frozenVersion}.tar.gz`); + writeFileSync(frozenTarget, frozenBytes); + copyFileSync(`${vendorArchive}.sha256`, `${frozenTarget}.sha256`); const latest = path.join(dist, "dl", "hstack.tar.gz"); copyFileSync(versioned, latest); const hash = crypto.createHash("sha256").update(readFileSync(versioned)).digest("hex"); + if (hash === frozenSha) { + throw new Error("0.2.0 archive matches the frozen 0.1.0 bytes"); + } writeFileSync(`${versioned}.sha256`, `${hash} hstack-v${version}.tar.gz\n`); writeFileSync(`${latest}.sha256`, `${hash} hstack.tar.gz\n`); for (const published of ["install.sh", "install.ps1"]) { diff --git a/site/scripts/check-no-html.mjs b/site/scripts/check-no-html.mjs index 8afddd6..0e400d3 100644 --- a/site/scripts/check-no-html.mjs +++ b/site/scripts/check-no-html.mjs @@ -36,6 +36,8 @@ const urls = new Set([ "/dl/hstack.tar.gz.sha256", "/dl/hstack-v0.1.0.tar.gz", "/dl/hstack-v0.1.0.tar.gz.sha256", + "/dl/hstack-v0.2.0.tar.gz", + "/dl/hstack-v0.2.0.tar.gz.sha256", ]); const external = new Set(); @@ -104,8 +106,16 @@ try { const { createHash } = await import("node:crypto"); const actual = createHash("sha256").update(archive).digest("hex"); if (actual !== digest) fail("hstack.tar.gz does not match its sha256"); - const versioned = Buffer.from(await (await fetch(`${base}/dl/hstack-v0.1.0.tar.gz`)).arrayBuffer()); - if (!versioned.equals(archive)) fail("latest alias is not the versioned archive"); + const current = Buffer.from(await (await fetch(`${base}/dl/hstack-v0.2.0.tar.gz`)).arrayBuffer()); + if (!current.equals(archive)) fail("latest alias is not hstack-v0.2.0.tar.gz"); + const frozen = Buffer.from(await (await fetch(`${base}/dl/hstack-v0.1.0.tar.gz`)).arrayBuffer()); + const frozenHash = createHash("sha256").update(frozen).digest("hex"); + if (frozenHash !== "0c8009ee04fb31126b76e4202c91eee14e8d5be7d1cd42ac0cee551012eeda6e") { + fail("hstack-v0.1.0.tar.gz bytes changed"); + } + if (frozen.equals(archive)) fail("latest alias is still the 0.1.0 archive"); + const frozenSum = await (await fetch(`${base}/dl/hstack-v0.1.0.tar.gz.sha256`)).text(); + if (!frozenSum.startsWith(`${frozenHash} hstack-v0.1.0.tar.gz`)) fail("v0.1.0 checksum file drifted"); const page = await (await fetch(`${base}/no-such-page`)).text(); if (!page.includes(" `
  • ${String(index + 1).padStart(2, "0")}${esc(skill.name)}${esc(skill.description)}${fmt(skill.body)}
  • `).join(""); const main = `
    01

    Hermes Agent skills

    Everything Hermes needs.
    Nothing it doesn't.

    Twelve skills. One router. A playbook loads only when the task needs it.

    -
    02

    Install

    Version 0.1.0. The archive is checked with sha256 and extracted into ~/.hermes/skills/hstack/. The installer does not clone the repository.

    ${install}

    Install, dry-run, and uninstall

    +
    02

    Install

    Version ${esc(skills[0].version)}. The archive is checked with sha256 and extracted into ~/.hermes/skills/hstack/. The installer does not clone the repository.

    ${install}

    Install, dry-run, and uninstall

    03

    Context

    Counts use Hermes's estimator, ceil(bytes / 4). The planning path is hstack, reframe, and shape: ${fmt(planning)} tokens, under 2% of GStack. The idle index is about six times smaller.

    @@ -457,7 +457,7 @@ export async function emitSite({ site, root, dist, skills, idle, planning, bodie pathname, title: skill.name, description: skill.description, - main: sheet(side(skillLinks, pathname, "Skills"), `

    Skill

    ${esc(skill.name)}

    ${skill.idle} idle · ${fmt(skill.body)} body · v0.1.0

    ${md.parse(stripped)}${extra}
    `), + main: sheet(side(skillLinks, pathname, "Skills"), `

    Skill

    ${esc(skill.name)}

    ${skill.idle} idle · ${fmt(skill.body)} body · v${esc(skill.version)}

    ${md.parse(stripped)}${extra}
    `), })); } diff --git a/site/vendor/dl/hstack-v0.1.0.tar.gz b/site/vendor/dl/hstack-v0.1.0.tar.gz new file mode 100644 index 0000000..8f30d6b Binary files /dev/null and b/site/vendor/dl/hstack-v0.1.0.tar.gz differ diff --git a/site/vendor/dl/hstack-v0.1.0.tar.gz.sha256 b/site/vendor/dl/hstack-v0.1.0.tar.gz.sha256 new file mode 100644 index 0000000..404d79d --- /dev/null +++ b/site/vendor/dl/hstack-v0.1.0.tar.gz.sha256 @@ -0,0 +1 @@ +0c8009ee04fb31126b76e4202c91eee14e8d5be7d1cd42ac0cee551012eeda6e hstack-v0.1.0.tar.gz diff --git a/site/vercel.json b/site/vercel.json index 7013e31..02614ee 100644 --- a/site/vercel.json +++ b/site/vercel.json @@ -87,6 +87,18 @@ { "key": "Cache-Control", "value": "public, max-age=31536000, immutable" } ] }, + { + "source": "/dl/hstack-v0.2.0.tar.gz", + "headers": [ + { "key": "Cache-Control", "value": "public, max-age=31536000, immutable" } + ] + }, + { + "source": "/dl/hstack-v0.2.0.tar.gz.sha256", + "headers": [ + { "key": "Cache-Control", "value": "public, max-age=31536000, immutable" } + ] + }, { "source": "/fonts/(.*)", "headers": [ diff --git a/skills/hstack/arena/SKILL.md b/skills/hstack/arena/SKILL.md index 55dba04..78bcd3a 100644 --- a/skills/hstack/arena/SKILL.md +++ b/skills/hstack/arena/SKILL.md @@ -1,7 +1,7 @@ --- name: arena description: Try competing approaches, then keep the best parts. -version: 0.1.0 +version: 0.2.0 author: Preetham Kyanam license: MIT platforms: [linux, macos, windows] diff --git a/skills/hstack/blast/SKILL.md b/skills/hstack/blast/SKILL.md index fc90c8e..211cd09 100644 --- a/skills/hstack/blast/SKILL.md +++ b/skills/hstack/blast/SKILL.md @@ -1,7 +1,7 @@ --- name: blast description: Find what a small diff can break, and prove the safe fact. -version: 0.1.0 +version: 0.2.0 author: Preetham Kyanam license: MIT platforms: [linux, macos, windows] diff --git a/skills/hstack/council/SKILL.md b/skills/hstack/council/SKILL.md index 121d3c8..c017c9e 100644 --- a/skills/hstack/council/SKILL.md +++ b/skills/hstack/council/SKILL.md @@ -1,7 +1,7 @@ --- name: council description: Have other models challenge the diff, then fix blockers. -version: 0.1.0 +version: 0.2.0 author: Preetham Kyanam license: MIT platforms: [linux, macos, windows] diff --git a/skills/hstack/hstack/SKILL.md b/skills/hstack/hstack/SKILL.md index 723f246..2430685 100644 --- a/skills/hstack/hstack/SKILL.md +++ b/skills/hstack/hstack/SKILL.md @@ -1,7 +1,7 @@ --- name: hstack description: Pick the playbook for this task and load it. -version: 0.1.0 +version: 0.2.0 author: Preetham Kyanam license: MIT platforms: [linux, macos, windows] @@ -26,3 +26,5 @@ Restate the goal in one sentence. If the evidence says the requested change is t Load the catalog with `skill_view("hstack", "references/routes.md")`. Pick one row. If the user only asked which playbook fits, name that file and stop. Load that one playbook with `skill_view("hstack", "references/")`. Do not load a second playbook. Say which file you loaded. + +Then load `skill_view("hstack", "references/stacks.md")`. Do that only after the playbook. If no outside stack fits better, do not mention one. If one does, add one line with its name and why, and ask. At most one. Load nothing from it, and download nothing, until the user says yes. A no lasts for this session. Follow that file for the commands. diff --git a/skills/hstack/hstack/references/memory.md b/skills/hstack/hstack/references/memory.md index 04954c5..574b2e2 100644 --- a/skills/hstack/hstack/references/memory.md +++ b/skills/hstack/hstack/references/memory.md @@ -6,3 +6,4 @@ Use this to save or restore working context. 2. Save only what the next session cannot get from the repo. Stay inside the tool's size caps. 3. A repeated lesson can become a skill with `/learn` when the user asks. 4. Do not install a second memory database. Do not rewrite project instruction files to point at one. +5. A session stamp may exist at `session-log.tsv` under the HStack directory in the Hermes home. Read the last line if the file is there. It is absent when the session hook is not installed. diff --git a/skills/hstack/hstack/references/safety.md b/skills/hstack/hstack/references/safety.md index 5ddd5b3..3a4e941 100644 --- a/skills/hstack/hstack/references/safety.md +++ b/skills/hstack/hstack/references/safety.md @@ -6,3 +6,4 @@ Use this when a command is destructive or writes should stay in one directory. 2. Stay inside the directory the user named. Do not patch outside it. The write sandbox is the enforcement. 3. Clear that limit only when the user says so. 4. Hooks exist for people who want them. Do not install one from this playbook. +5. `pre_command` cannot block a tool. The optional guard is a `pre_tool_call` hook on `terminal`, and only the installer flag adds it. diff --git a/skills/hstack/hstack/references/stacks.md b/skills/hstack/hstack/references/stacks.md new file mode 100644 index 0000000..d98d2b5 --- /dev/null +++ b/skills/hstack/hstack/references/stacks.md @@ -0,0 +1,61 @@ +# External stacks + +Load this after one HStack playbook, not before. This file is not a playbook. Do not load a second playbook from here. + +Run `python3` on `skills/hstack/hstack/scripts/hstack_stack.py` inside the Hermes home, with `detect` and `--session` set to this chat. The script reads files and config. It does not run the other stack, and it does not download. + +Suggest at most one stack. Prefer one the script marks installed when it fits this task better than the playbook you already loaded. Say the name and why in one line, then ask. + +Load nothing from that stack, and download nothing, until the user says yes. + +On no, run the script's `decline` with the same session and that id. Do not suggest it again this session. + +On yes to an install, run `hstack_stack.py install` with `--yes` and the same session. That flag is the user's yes. Do not add it yourself. The script checks the pinned sha256 before it extracts, into the HStack external directory, and it does not copy those files into HStack skills. Then hand off: name the path and stop. Do not read their skill text until the user says yes to that too. + +A stack that is not installed can be the one suggestion only when no installed stack fits. Show the install command the script printed. Do not run it until the user says yes. + +The fenced block is the pin list. Detect without running the stack: + +- gstack: a SKILL.md under the Hermes `skills/gstack` directory, or `gstack-state-root.sh` inside `~/.claude/skills/gstack/bin` or the Codex twin. Do not execute that file. `~/.gstack` is state, not an install. +- pstack: a file ending in `skills/poteto-mode/SKILL.md` under `~/.cursor/plugins` or `~/.claude/plugins`. +- tstack: one directory holds both `leaving-pr-comments/SKILL.md` and `sol-loop/SKILL.md` under Claude, Codex, agents, or Hermes skills. +- gbrain: `config.json` in `~/.gbrain`, or in `$GBRAIN_HOME/.gbrain` when that variable is set, or an `mcp_servers.gbrain` entry. A binary on PATH is not enough. + +```json +{ + "stacks": [ + { + "id": "gstack", + "good_at": "Product framing, design review, QA, and release skills written for Claude Code.", + "license": "MIT", + "commit": "9e3308f65adf547b7e4776e5ed22467a2649d2ee", + "sha256": "3beaffdfc83012c6784c7f0610b580290c7e0b41a416e230d4ed63df7311ebc7", + "archive": "https://codeload.github.com/garrytan/gstack/tar.gz/9e3308f65adf547b7e4776e5ed22467a2649d2ee" + }, + { + "id": "pstack", + "good_at": "Rigorous playbooks, parallel subagents, and principle skills. The tree's skills are under pstack/.", + "license": "MIT", + "commit": "c742dd61706fbacc1b95f1a20b7b988cf096ce6c", + "sha256": "4f899c71d737afe7662ff515df0518b193562e4f9ec370ffb2d7d837aaf436ef", + "archive": "https://codeload.github.com/cursor/plugins/tar.gz/c742dd61706fbacc1b95f1a20b7b988cf096ce6c" + }, + { + "id": "tstack", + "good_at": "Short pull-request skills: file a PR, a review council, takeover, and modernize.", + "license": "MIT", + "commit": "1085050f3baed1ac724caa4a8166145fb2ac88af", + "sha256": "9ef4b8abe9a8595f2309fdb096e4c41569b26920bcf6e91fba07854a9092cf1c", + "archive": "https://codeload.github.com/t3dotgg/tstack/tar.gz/1085050f3baed1ac724caa4a8166145fb2ac88af" + }, + { + "id": "gbrain", + "good_at": "A personal knowledge store. It is not Hermes memory unless this install is present.", + "license": "MIT", + "commit": "82b7ef601fdfc449b0d242f7e61a4604aa2c8a8e", + "sha256": "b7127348f6ed1163c32106957844af637426d491c669a3ee68e989f2c68ed451", + "archive": "https://codeload.github.com/garrytan/gbrain/tar.gz/82b7ef601fdfc449b0d242f7e61a4604aa2c8a8e" + } + ] +} +``` diff --git a/skills/hstack/hstack/scripts/hstack_stack.py b/skills/hstack/hstack/scripts/hstack_stack.py new file mode 100644 index 0000000..dc78624 --- /dev/null +++ b/skills/hstack/hstack/scripts/hstack_stack.py @@ -0,0 +1,406 @@ +#!/usr/bin/env python3 +"""Detect, decline, or install an external stack. + +Detection only reads files. Install downloads only when --yes is present, +checks the registry sha256 before extract, and writes outside the HStack +skill tree. It does not run the other project. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import re +import shutil +import sys +import tarfile +import tempfile +import urllib.request +from pathlib import Path + + +SESSION_RE = re.compile(r"^[A-Za-z0-9._-]{1,64}$") +COMMIT_RE = re.compile(r"^[0-9a-f]{40}$") +MAX_BYTES = 100 * 1024 * 1024 + + +def say(message: str) -> None: + print(message) + + +def die(message: str, code: int = 1) -> None: + print(f"hstack-stack: {message}", file=sys.stderr) + raise SystemExit(code) + + +def hermes_home(raw: str | None) -> Path: + if raw: + return Path(raw) + env = os.environ.get("HERMES_HOME") + if env: + return Path(env) + return Path.home() / ".hermes" + + +def user_home(raw: str | None) -> Path: + if raw: + return Path(raw) + return Path.home() + + +def default_registry() -> Path: + return Path(__file__).resolve().parents[1] / "references" / "stacks.md" + + +def load_registry(path: Path) -> list[dict]: + text = path.read_text(encoding="utf-8") + match = re.search(r"```json\n(.*?)\n```", text, re.DOTALL) + if not match: + die(f"registry has no json block: {path}") + try: + data = json.loads(match.group(1)) + except json.JSONDecodeError as exc: + die(f"registry json is invalid: {exc}") + stacks = data.get("stacks") + if not isinstance(stacks, list) or not stacks: + die("registry has no stacks") + return stacks + + +def stack_by_id(stacks: list[dict], stack_id: str) -> dict: + for item in stacks: + if item.get("id") == stack_id: + return item + die(f"unknown stack: {stack_id}") + raise AssertionError + + +def check_session(session: str) -> str: + if not SESSION_RE.match(session or ""): + die("session id must be 1-64 letters, numbers, dots, underscores, or dashes") + return session + + +def decline_path(home: Path, session: str) -> Path: + return home / "hstack" / "declines" / session + + +def read_declines(home: Path, session: str | None) -> set[str]: + if not session: + return set() + path = decline_path(home, session) + if not path.is_file(): + return set() + return {line.strip() for line in path.read_text(encoding="utf-8").splitlines() if line.strip()} + + +def is_symlink(path: Path) -> bool: + return path.is_symlink() + + +def skill_file(path: Path) -> bool: + return path.is_file() and not path.is_symlink() + + +def walk_suffix(root: Path, suffix: str, max_depth: int = 8) -> bool: + if not root.is_dir() or root.is_symlink(): + return False + needle = tuple(suffix.strip("/").split("/")) + for dirpath, dirnames, filenames in os.walk(root, followlinks=False): + current = Path(dirpath) + rel_parts = current.relative_to(root).parts + if len(rel_parts) > max_depth: + dirnames[:] = [] + continue + dirnames[:] = [ + name + for name in dirnames + if name not in {".git", "node_modules"} and not (current / name).is_symlink() + ] + if "SKILL.md" not in filenames: + continue + parts = current.parts + if len(parts) >= len(needle) - 1 and parts[-(len(needle) - 1) :] == needle[:-1]: + candidate = current / needle[-1] + if skill_file(candidate): + return True + return False + + +def pair_present(root: Path) -> bool: + if not root.is_dir() or root.is_symlink(): + return False + levels = [root] + try: + children = [child for child in root.iterdir() if child.is_dir() and not child.is_symlink()] + except OSError: + return False + levels.extend(children[:80]) + for child in children[:40]: + try: + levels.extend(grand for grand in child.iterdir() if grand.is_dir() and not grand.is_symlink()) + except OSError: + continue + for directory in levels: + left = directory / "leaving-pr-comments" / "SKILL.md" + right = directory / "sol-loop" / "SKILL.md" + if skill_file(left) and skill_file(right): + return True + return False + + +def gbrain_config(home: Path) -> Path: + parent = os.environ.get("GBRAIN_HOME") + if parent: + return Path(parent) / ".gbrain" / "config.json" + return home / ".gbrain" / "config.json" + + +def mcp_gbrain(config: Path) -> bool: + if not config.is_file() or config.is_symlink(): + return False + lines = config.read_text(encoding="utf-8").splitlines() + inside = False + for line in lines: + if line.startswith("mcp_servers:"): + inside = True + continue + if inside and line and not line.startswith(" ") and not line.startswith("#"): + inside = False + if inside and line.startswith(" gbrain:"): + return True + return False + + +def gbrain_skills(roots: list[Path]) -> bool: + for root in roots: + if not root.is_dir() or root.is_symlink(): + continue + for dirpath, dirnames, _filenames in os.walk(root, followlinks=False): + current = Path(dirpath) + depth = len(current.relative_to(root).parts) + if depth > 4: + dirnames[:] = [] + continue + dirnames[:] = [name for name in dirnames if not (current / name).is_symlink()] + if skill_file(current / "gbrain-upgrade" / "SKILL.md") and skill_file(current / "brain-ops" / "SKILL.md"): + return True + return False + + +def receipt_installed(hermes: Path, stack_id: str, commit: str) -> bool: + receipt = hermes / "hstack" / "external" / stack_id / commit / ".hstack-external" + return receipt.is_file() and not receipt.is_symlink() + + +def gstack_installed(hermes: Path, home: Path) -> bool: + category = hermes / "skills" / "gstack" + if category.is_dir() and not category.is_symlink(): + for child in category.iterdir(): + if child.is_dir() and skill_file(child / "SKILL.md"): + return True + for base in (home / ".claude" / "skills" / "gstack", home / ".codex" / "skills" / "gstack"): + marker = base / "bin" / "gstack-state-root.sh" + if marker.is_file(): + return True + return False + + +def pstack_installed(home: Path) -> bool: + for root in (home / ".cursor" / "plugins", home / ".claude" / "plugins"): + if walk_suffix(root, "skills/poteto-mode/SKILL.md"): + return True + return False + + +def tstack_installed(hermes: Path, home: Path) -> bool: + roots = [ + home / ".claude" / "skills", + home / ".codex" / "skills", + home / ".agents" / "skills", + hermes / "skills", + ] + return any(pair_present(root) for root in roots) + + +def gbrain_installed(hermes: Path, home: Path) -> bool: + config = gbrain_config(home) + if config.is_file() and not config.is_symlink(): + return True + if mcp_gbrain(hermes / "config.yaml"): + return True + return gbrain_skills([hermes / "skills", home / ".claude" / "skills", home / ".codex" / "skills"]) + + +def installed(stack_id: str, hermes: Path, home: Path, commit: str) -> bool: + if receipt_installed(hermes, stack_id, commit): + return True + if stack_id == "gstack": + return gstack_installed(hermes, home) + if stack_id == "pstack": + return pstack_installed(home) + if stack_id == "tstack": + return tstack_installed(hermes, home) + if stack_id == "gbrain": + return gbrain_installed(hermes, home) + return False + + +def script_path() -> str: + return str(Path(__file__).resolve()) + + +def detect(stacks: list[dict], hermes: Path, home: Path, session: str | None) -> int: + declined = read_declines(hermes, session) + for item in stacks: + stack_id = str(item["id"]) + commit = str(item["commit"]) + present = installed(stack_id, hermes, home, commit) + refused = stack_id in declined + fields = [ + f"id={stack_id}", + f"installed={'yes' if present else 'no'}", + f"declined={'yes' if refused else 'no'}", + f"license={item.get('license', '')}", + f"commit={commit}", + f"good_at={item.get('good_at', '')}", + ] + say(" ".join(fields)) + if not present and not refused and session: + say( + "install_command=" + + script_path() + + f" install {stack_id} --yes --session {session}" + ) + return 0 + + +def decline(stack_id: str, hermes: Path, session: str) -> int: + path = decline_path(hermes, session) + path.parent.mkdir(parents=True, exist_ok=True) + current = read_declines(hermes, session) + current.add(stack_id) + path.write_text("".join(f"{item}\n" for item in sorted(current)), encoding="utf-8") + say(f"declined id={stack_id} session={session}") + return 0 + + +def safe_members(archive: tarfile.TarFile) -> list[tarfile.TarInfo]: + members = [] + for member in archive.getmembers(): + name = member.name + if not name or name.startswith("/") or name.startswith("\\"): + die(f"archive member is absolute: {name}") + parts = Path(name).parts + if ".." in parts: + die(f"archive member contains ..: {name}") + if member.issym() or member.islnk() or member.isdev() or member.isfifo(): + die(f"archive member is a link or special file: {name}") + members.append(member) + return members + + +def download(url: str, dest: Path) -> None: + parsed = urllib.request.urlparse(url) + if parsed.scheme == "http" and parsed.hostname not in {"127.0.0.1", "localhost"}: + die("refusing a non-local http archive") + if parsed.scheme not in {"https", "http"}: + die("refusing archive URL") + request = urllib.request.Request(url, headers={"User-Agent": "hstack-stack"}) + with urllib.request.urlopen(request, timeout=120) as response, dest.open("wb") as handle: + total = 0 + while True: + chunk = response.read(1024 * 256) + if not chunk: + break + total += len(chunk) + if total > MAX_BYTES: + die("archive is larger than the limit") + handle.write(chunk) + + +def install(item: dict, hermes: Path, session: str, yes: bool) -> int: + stack_id = str(item["id"]) + if not yes: + die("refusing to download without --yes", 2) + if stack_id in read_declines(hermes, session): + die(f"refusing to install {stack_id}; this session declined it", 2) + commit = str(item["commit"]) + digest = str(item["sha256"]).lower() + url = str(item["archive"]) + if not COMMIT_RE.match(commit) or not re.fullmatch(r"[0-9a-f]{64}", digest): + die("registry pin is not a commit and sha256") + dest = hermes / "hstack" / "external" / stack_id / commit + receipt = dest / ".hstack-external" + if receipt.is_file(): + text = receipt.read_text(encoding="utf-8") + if f"sha256={digest}" in text and f"commit={commit}" in text: + say(f"already installed id={stack_id} path={dest}") + say("handoff: files are outside HStack skills. Do not read them until the user says yes.") + return 0 + work = Path(tempfile.mkdtemp(prefix="hstack-stack-")) + try: + archive_path = work / "stack.tar.gz" + download(url, archive_path) + actual = hashlib.sha256(archive_path.read_bytes()).hexdigest() + if actual != digest: + die("checksum mismatch") + magic = archive_path.read_bytes()[:2] + if magic != b"\x1f\x8b": + die("download was not a gzip archive") + with tarfile.open(archive_path, "r:gz") as archive: + members = safe_members(archive) + archive.extractall(work / "tree", members=members) + extracted = work / "tree" + tops = [child for child in extracted.iterdir() if child.is_dir()] + if len(tops) != 1: + die("expected one top-level directory in the archive") + if dest.exists(): + shutil.rmtree(dest) + dest.parent.mkdir(parents=True, exist_ok=True) + shutil.move(str(tops[0]), str(dest)) + receipt.write_text( + f"id={stack_id}\ncommit={commit}\nsha256={digest}\n", + encoding="utf-8", + ) + finally: + shutil.rmtree(work, ignore_errors=True) + say(f"installed id={stack_id} commit={commit} path={dest}") + say("handoff: files are outside HStack skills. Do not read them until the user says yes.") + return 0 + + +def main() -> int: + parser = argparse.ArgumentParser(prog="hstack_stack") + parser.add_argument("action", choices=("detect", "decline", "install")) + parser.add_argument("stack_id", nargs="?") + parser.add_argument("--session", default="") + parser.add_argument("--yes", action="store_true") + parser.add_argument("--hermes-home", default="") + parser.add_argument("--home", default="") + parser.add_argument("--registry", default="") + args = parser.parse_args() + hermes = hermes_home(args.hermes_home or None) + home = user_home(args.home or None) + registry = Path(args.registry) if args.registry else default_registry() + if not registry.is_file(): + die(f"missing registry {registry}") + stacks = load_registry(registry) + if args.action == "detect": + session = args.session or None + if session: + check_session(session) + return detect(stacks, hermes, home, session) + if not args.stack_id: + die("missing stack id") + item = stack_by_id(stacks, args.stack_id) + session = check_session(args.session) + if args.action == "decline": + return decline(args.stack_id, hermes, session) + return install(item, hermes, session, args.yes) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/skills/hstack/modernize/SKILL.md b/skills/hstack/modernize/SKILL.md index 3d83a08..9474e42 100644 --- a/skills/hstack/modernize/SKILL.md +++ b/skills/hstack/modernize/SKILL.md @@ -1,7 +1,7 @@ --- name: modernize description: Rebase an old PR and decide if it still belongs. -version: 0.1.0 +version: 0.2.0 author: Preetham Kyanam license: MIT platforms: [linux, macos, windows] diff --git a/skills/hstack/principles/SKILL.md b/skills/hstack/principles/SKILL.md index 6fb3533..ac0f670 100644 --- a/skills/hstack/principles/SKILL.md +++ b/skills/hstack/principles/SKILL.md @@ -1,7 +1,7 @@ --- name: principles description: Apply the design rule that should change this decision. -version: 0.1.0 +version: 0.2.0 author: Preetham Kyanam license: MIT platforms: [linux, macos, windows] diff --git a/skills/hstack/prove/SKILL.md b/skills/hstack/prove/SKILL.md index a0a1945..b310c5e 100644 --- a/skills/hstack/prove/SKILL.md +++ b/skills/hstack/prove/SKILL.md @@ -1,7 +1,7 @@ --- name: prove description: Show a failing check, then a passing one, on real behavior. -version: 0.1.0 +version: 0.2.0 author: Preetham Kyanam license: MIT platforms: [linux, macos, windows] diff --git a/skills/hstack/reframe/SKILL.md b/skills/hstack/reframe/SKILL.md index 7a85e86..3a08969 100644 --- a/skills/hstack/reframe/SKILL.md +++ b/skills/hstack/reframe/SKILL.md @@ -1,7 +1,7 @@ --- name: reframe description: Name the real job before any plan or code. -version: 0.1.0 +version: 0.2.0 author: Preetham Kyanam license: MIT platforms: [linux, macos, windows] diff --git a/skills/hstack/shape/SKILL.md b/skills/hstack/shape/SKILL.md index dbc9cec..629d63a 100644 --- a/skills/hstack/shape/SKILL.md +++ b/skills/hstack/shape/SKILL.md @@ -1,7 +1,7 @@ --- name: shape description: Settle data shape and module boundaries first. -version: 0.1.0 +version: 0.2.0 author: Preetham Kyanam license: MIT platforms: [linux, macos, windows] diff --git a/skills/hstack/ship/SKILL.md b/skills/hstack/ship/SKILL.md index c92eb71..141d780 100644 --- a/skills/hstack/ship/SKILL.md +++ b/skills/hstack/ship/SKILL.md @@ -1,7 +1,7 @@ --- name: ship description: Open a short PR, watch CI, and merge only if told. -version: 0.1.0 +version: 0.2.0 author: Preetham Kyanam license: MIT platforms: [linux, macos, windows] diff --git a/skills/hstack/swarm/SKILL.md b/skills/hstack/swarm/SKILL.md index 549e3eb..af7a176 100644 --- a/skills/hstack/swarm/SKILL.md +++ b/skills/hstack/swarm/SKILL.md @@ -1,7 +1,7 @@ --- name: swarm description: Fan out parallel subagents and return one report. -version: 0.1.0 +version: 0.2.0 author: Preetham Kyanam license: MIT platforms: [linux, macos, windows] diff --git a/skills/hstack/takeover/SKILL.md b/skills/hstack/takeover/SKILL.md index 85c3361..9634cd8 100644 --- a/skills/hstack/takeover/SKILL.md +++ b/skills/hstack/takeover/SKILL.md @@ -1,7 +1,7 @@ --- name: takeover description: Finish someone else's branch and keep their git credit. -version: 0.1.0 +version: 0.2.0 author: Preetham Kyanam license: MIT platforms: [linux, macos, windows]
    Measured from this repository. GStack figures are the Hermes host.
    MeasureGStack on HermesHStack
    Idle index${fmt(gstack.idle)}${fmt(idle)}