hx-cli 0.2.8__tar.gz → 0.2.9__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {hx_cli-0.2.8 → hx_cli-0.2.9}/CHANGELOG.md +19 -1
- {hx_cli-0.2.8 → hx_cli-0.2.9}/PKG-INFO +8 -1
- {hx_cli-0.2.8 → hx_cli-0.2.9}/README.md +7 -0
- {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/__init__.py +1 -1
- {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/cli.py +16 -5
- {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/core/context.py +45 -11
- {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/core/loop.py +4 -1
- {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/paths.py +36 -1
- {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/tui/commands.py +9 -5
- {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/tui/format.py +0 -15
- {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/tui/runtime.py +1 -1
- {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/tui/views/pickers.py +2 -1
- {hx_cli-0.2.8 → hx_cli-0.2.9}/tests/e2e/test_cli.py +10 -1
- {hx_cli-0.2.8 → hx_cli-0.2.9}/tests/e2e/test_commands.py +28 -0
- {hx_cli-0.2.8 → hx_cli-0.2.9}/tests/e2e/test_extend.py +79 -0
- {hx_cli-0.2.8 → hx_cli-0.2.9}/.github/workflows/ci.yml +0 -0
- {hx_cli-0.2.8 → hx_cli-0.2.9}/.gitignore +0 -0
- {hx_cli-0.2.8 → hx_cli-0.2.9}/examples/hooks/check.sh +0 -0
- {hx_cli-0.2.8 → hx_cli-0.2.9}/install.sh +0 -0
- {hx_cli-0.2.8 → hx_cli-0.2.9}/pyproject.toml +0 -0
- {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/agents/__init__.py +0 -0
- {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/agents/definitions.py +0 -0
- {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/agents/subagent.py +0 -0
- {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/auth/__init__.py +0 -0
- {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/auth/oauth/__init__.py +0 -0
- {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/auth/oauth/browser.py +0 -0
- {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/auth/oauth/callback.py +0 -0
- {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/auth/oauth/codex.py +0 -0
- {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/auth/oauth/devin.py +0 -0
- {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/auth/oauth/pkce.py +0 -0
- {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/auth/resolve.py +0 -0
- {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/auth/store.py +0 -0
- {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/config.py +0 -0
- {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/core/__init__.py +0 -0
- {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/core/checkpoints.py +0 -0
- {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/core/compaction.py +0 -0
- {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/core/events.py +0 -0
- {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/core/images.py +0 -0
- {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/core/lateinject.py +0 -0
- {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/core/messages.py +0 -0
- {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/core/session.py +0 -0
- {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/core/title.py +0 -0
- {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/core/usage.py +0 -0
- {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/docs.py +0 -0
- {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/frontmatter.py +0 -0
- {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/git.py +0 -0
- {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/hooks/__init__.py +0 -0
- {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/hooks/engine.py +0 -0
- {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/hooks/spec.py +0 -0
- {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/keys.py +0 -0
- {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/mcp/__init__.py +0 -0
- {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/mcp/client.py +0 -0
- {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/mcp/manager.py +0 -0
- {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/mcp/oauth.py +0 -0
- {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/net.py +0 -0
- {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/permissions/__init__.py +0 -0
- {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/permissions/engine.py +0 -0
- {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/permissions/parser.py +0 -0
- {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/permissions/sandbox.py +0 -0
- {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/providers/__init__.py +0 -0
- {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/providers/base.py +0 -0
- {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/providers/codex.py +0 -0
- {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/providers/codex_catalogue.py +0 -0
- {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/providers/devin.py +0 -0
- {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/providers/devin_catalogue.py +0 -0
- {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/providers/devin_wire.py +0 -0
- {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/providers/models.py +0 -0
- {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/providers/openrouter.py +0 -0
- {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/providers/protowire.py +0 -0
- {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/providers/registry.py +0 -0
- {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/providers/responses_codec.py +0 -0
- {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/skills/__init__.py +0 -0
- {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/skills/loader.py +0 -0
- {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/skills/runtime.py +0 -0
- {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/term/__init__.py +0 -0
- {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/term/ansi.py +0 -0
- {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/term/buffer.py +0 -0
- {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/term/component.py +0 -0
- {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/term/demo.py +0 -0
- {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/term/editor.py +0 -0
- {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/term/keydecode.py +0 -0
- {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/term/loop.py +0 -0
- {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/term/markdown.py +0 -0
- {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/term/modifiers.py +0 -0
- {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/term/primitives.py +0 -0
- {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/term/sanitize.py +0 -0
- {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/term/screen.py +0 -0
- {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/term/syntax.py +0 -0
- {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/term/terminal.py +0 -0
- {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/term/undo.py +0 -0
- {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/term/width.py +0 -0
- {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/term/word_nav.py +0 -0
- {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/tools/__init__.py +0 -0
- {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/tools/anchors.py +0 -0
- {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/tools/base.py +0 -0
- {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/tools/bash.py +0 -0
- {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/tools/edit.py +0 -0
- {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/tools/glob.py +0 -0
- {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/tools/grep.py +0 -0
- {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/tools/output.py +0 -0
- {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/tools/read.py +0 -0
- {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/tools/registry.py +0 -0
- {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/tools/symbols.py +0 -0
- {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/tools/task.py +0 -0
- {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/tools/todo.py +0 -0
- {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/tools/websearch.py +0 -0
- {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/tools/write.py +0 -0
- {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/trace.py +0 -0
- {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/tui/__init__.py +0 -0
- {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/tui/app.py +0 -0
- {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/tui/clipboard.py +0 -0
- {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/tui/fuzzy.py +0 -0
- {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/tui/glyphs.py +0 -0
- {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/tui/killring.py +0 -0
- {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/tui/limits.py +0 -0
- {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/tui/paint.py +0 -0
- {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/tui/renderers.py +0 -0
- {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/tui/roles.py +0 -0
- {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/tui/theme.py +0 -0
- {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/tui/theme_json.py +0 -0
- {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/tui/themes/ansi.json +0 -0
- {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/tui/themes/dark.json +0 -0
- {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/tui/themes/light.json +0 -0
- {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/tui/views/__init__.py +0 -0
- {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/tui/views/blocks.py +0 -0
- {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/tui/views/dialog.py +0 -0
- {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/tui/views/login.py +0 -0
- {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/tui/views/permission.py +0 -0
- {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/tui/views/prompt.py +0 -0
- {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/tui/views/status.py +0 -0
- {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/tui/views/transcript.py +0 -0
- {hx_cli-0.2.8 → hx_cli-0.2.9}/tests/__init__.py +0 -0
- {hx_cli-0.2.8 → hx_cli-0.2.9}/tests/conftest.py +0 -0
- {hx_cli-0.2.8 → hx_cli-0.2.9}/tests/e2e/__init__.py +0 -0
- {hx_cli-0.2.8 → hx_cli-0.2.9}/tests/e2e/conftest.py +0 -0
- {hx_cli-0.2.8 → hx_cli-0.2.9}/tests/e2e/fixtures/echo_server.py +0 -0
- {hx_cli-0.2.8 → hx_cli-0.2.9}/tests/e2e/report.py +0 -0
- {hx_cli-0.2.8 → hx_cli-0.2.9}/tests/e2e/stub.py +0 -0
- {hx_cli-0.2.8 → hx_cli-0.2.9}/tests/e2e/terminal.py +0 -0
- {hx_cli-0.2.8 → hx_cli-0.2.9}/tests/e2e/test_chat.py +0 -0
- {hx_cli-0.2.8 → hx_cli-0.2.9}/tests/e2e/test_input.py +0 -0
- {hx_cli-0.2.8 → hx_cli-0.2.9}/tests/e2e/test_print.py +0 -0
- {hx_cli-0.2.8 → hx_cli-0.2.9}/tests/e2e/test_tools.py +0 -0
- {hx_cli-0.2.8 → hx_cli-0.2.9}/tests/test_live.py +0 -0
- {hx_cli-0.2.8 → hx_cli-0.2.9}/tests/test_live_codex.py +0 -0
- {hx_cli-0.2.8 → hx_cli-0.2.9}/tests/test_live_devin.py +0 -0
- {hx_cli-0.2.8 → hx_cli-0.2.9}/tests/test_live_tavily.py +0 -0
- {hx_cli-0.2.8 → hx_cli-0.2.9}/uv.lock +0 -0
|
@@ -28,6 +28,23 @@ the project follows [semantic versioning](https://semver.org/).
|
|
|
28
28
|
|
|
29
29
|
---
|
|
30
30
|
|
|
31
|
+
## [0.2.9] - 2026-09-27
|
|
32
|
+
|
|
33
|
+
### Added
|
|
34
|
+
|
|
35
|
+
- `~/.hx/AGENTS.md` holds your own instructions and is loaded into every
|
|
36
|
+
session in every project, ahead of the project's `AGENTS.md`. The first
|
|
37
|
+
session creates it empty, ready to fill in. `hx prompt` and `/prompt` now
|
|
38
|
+
name each `AGENTS.md` in force, and `/prompt` says when one has changed on
|
|
39
|
+
disk since the session started.
|
|
40
|
+
|
|
41
|
+
### Fixed
|
|
42
|
+
|
|
43
|
+
- An `AGENTS.md` or system-prompt override that is not valid UTF-8 is skipped
|
|
44
|
+
like an unreadable one, instead of stopping HX from starting.
|
|
45
|
+
|
|
46
|
+
---
|
|
47
|
+
|
|
31
48
|
## [0.2.8] - 2026-09-27
|
|
32
49
|
|
|
33
50
|
### Changed
|
|
@@ -725,7 +742,8 @@ First release, published to PyPI as [`hx-cli`](https://pypi.org/project/hx-cli/)
|
|
|
725
742
|
- `/configure` and `hx auth` for the OpenRouter key, `hx upgrade` for
|
|
726
743
|
self-update, and `install.sh` bootstrapping uv with a pinned Python.
|
|
727
744
|
|
|
728
|
-
[Unreleased]: https://github.com/aletisunil/hx/compare/v0.2.
|
|
745
|
+
[Unreleased]: https://github.com/aletisunil/hx/compare/v0.2.9...HEAD
|
|
746
|
+
[0.2.9]: https://github.com/aletisunil/hx/compare/v0.2.8...v0.2.9
|
|
729
747
|
[0.2.8]: https://github.com/aletisunil/hx/compare/v0.2.7...v0.2.8
|
|
730
748
|
[0.2.7]: https://github.com/aletisunil/hx/compare/v0.2.6...v0.2.7
|
|
731
749
|
[0.2.6]: https://github.com/aletisunil/hx/compare/v0.2.5...v0.2.6
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: hx-cli
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.9
|
|
4
4
|
Summary: HX - an agent harness for the terminal
|
|
5
5
|
Project-URL: Homepage, https://hx.sunilaleti.dev
|
|
6
6
|
Project-URL: Documentation, https://hx.sunilaleti.dev
|
|
@@ -753,6 +753,7 @@ files load here unchanged. `src/hx/tui/themes/dark.json` is the reference.
|
|
|
753
753
|
| `~/.hx/keybindings.json` | your key overrides |
|
|
754
754
|
| `~/.hx/system-prompt.md` | your system prompt, replacing the built-in one |
|
|
755
755
|
| `~/.hx/system-prompt-append.md` | text appended to whichever prompt is in force |
|
|
756
|
+
| `~/.hx/AGENTS.md` | your instructions, loaded into every session in every project; created empty |
|
|
756
757
|
| `~/.hx/sessions/` | transcripts, spilled tool output, subagent sessions |
|
|
757
758
|
| `~/.hx/sessions/<id>/trace.html` | where `/trace` writes, unless you name a path |
|
|
758
759
|
| `~/.hx/skills/`, `~/.hx/agents/` | your skills and agents |
|
|
@@ -767,6 +768,12 @@ files load here unchanged. `src/hx/tui/themes/dark.json` is the reference.
|
|
|
767
768
|
tests, conventions, what not to touch. `/init` writes a first draft. It is
|
|
768
769
|
loaded once per session and frozen, so it costs one prefix, not one per turn.
|
|
769
770
|
|
|
771
|
+
`~/.hx/AGENTS.md` is the same thing for you rather than the project: how you
|
|
772
|
+
like commits written, tools you always want used, in every repository. It is
|
|
773
|
+
loaded first and the project's `AGENTS.md` after it, so where the two disagree
|
|
774
|
+
the project has the last word. The first session creates it empty, ready to
|
|
775
|
+
fill in; empty, it adds nothing. `hx prompt` and `/prompt` list each one in force.
|
|
776
|
+
|
|
770
777
|
---
|
|
771
778
|
|
|
772
779
|
## Safety
|
|
@@ -713,6 +713,7 @@ files load here unchanged. `src/hx/tui/themes/dark.json` is the reference.
|
|
|
713
713
|
| `~/.hx/keybindings.json` | your key overrides |
|
|
714
714
|
| `~/.hx/system-prompt.md` | your system prompt, replacing the built-in one |
|
|
715
715
|
| `~/.hx/system-prompt-append.md` | text appended to whichever prompt is in force |
|
|
716
|
+
| `~/.hx/AGENTS.md` | your instructions, loaded into every session in every project; created empty |
|
|
716
717
|
| `~/.hx/sessions/` | transcripts, spilled tool output, subagent sessions |
|
|
717
718
|
| `~/.hx/sessions/<id>/trace.html` | where `/trace` writes, unless you name a path |
|
|
718
719
|
| `~/.hx/skills/`, `~/.hx/agents/` | your skills and agents |
|
|
@@ -727,6 +728,12 @@ files load here unchanged. `src/hx/tui/themes/dark.json` is the reference.
|
|
|
727
728
|
tests, conventions, what not to touch. `/init` writes a first draft. It is
|
|
728
729
|
loaded once per session and frozen, so it costs one prefix, not one per turn.
|
|
729
730
|
|
|
731
|
+
`~/.hx/AGENTS.md` is the same thing for you rather than the project: how you
|
|
732
|
+
like commits written, tools you always want used, in every repository. It is
|
|
733
|
+
loaded first and the project's `AGENTS.md` after it, so where the two disagree
|
|
734
|
+
the project has the last word. The first session creates it empty, ready to
|
|
735
|
+
fill in; empty, it adds nothing. `hx prompt` and `/prompt` list each one in force.
|
|
736
|
+
|
|
730
737
|
---
|
|
731
738
|
|
|
732
739
|
## Safety
|
|
@@ -346,7 +346,12 @@ def build_runtime(parsed: ParsedArgs, *, resume: str | None = None) -> Runtime:
|
|
|
346
346
|
from hx.config import load_settings
|
|
347
347
|
from hx.core.checkpoints import CheckpointStore
|
|
348
348
|
from hx.core.compaction import Compactor
|
|
349
|
-
from hx.core.context import
|
|
349
|
+
from hx.core.context import (
|
|
350
|
+
ContextBuilder,
|
|
351
|
+
build_project_context,
|
|
352
|
+
load_instructions,
|
|
353
|
+
load_system_prompt,
|
|
354
|
+
)
|
|
350
355
|
from hx.core.events import EventBus
|
|
351
356
|
from hx.core.lateinject import Injection, InjectionRegistry
|
|
352
357
|
from hx.core.loop import AgentLoop
|
|
@@ -430,6 +435,7 @@ def build_runtime(parsed: ParsedArgs, *, resume: str | None = None) -> Runtime:
|
|
|
430
435
|
settings.cwd,
|
|
431
436
|
keep_recent_turns=settings.context.keep_recent_turns,
|
|
432
437
|
)
|
|
438
|
+
instructions = load_instructions(settings.cwd)
|
|
433
439
|
|
|
434
440
|
checkpoints = CheckpointStore(session, session_checkpoints_dir(session.meta.session_id))
|
|
435
441
|
tools = build_default_registry(shell, jobs, tracker, todos, bus_holder, auth, checkpoints)
|
|
@@ -478,7 +484,8 @@ def build_runtime(parsed: ParsedArgs, *, resume: str | None = None) -> Runtime:
|
|
|
478
484
|
settings=settings,
|
|
479
485
|
model_info=model_info,
|
|
480
486
|
skills_index=build_index(list(skills.values())) or None,
|
|
481
|
-
project_context=build_project_context(settings.cwd),
|
|
487
|
+
project_context=build_project_context(settings.cwd, instructions),
|
|
488
|
+
instructions=instructions,
|
|
482
489
|
hooks=hooks,
|
|
483
490
|
)
|
|
484
491
|
|
|
@@ -842,15 +849,17 @@ def run_print_command(parsed: ParsedArgs) -> int:
|
|
|
842
849
|
def run_prompt_command(parsed: ParsedArgs) -> int:
|
|
843
850
|
"""``hx prompt`` - print the system prompt this directory resolves to.
|
|
844
851
|
|
|
845
|
-
The prompt goes to stdout so it can be piped or diffed; where it came from
|
|
846
|
-
goes to stderr so it never
|
|
852
|
+
The prompt goes to stdout so it can be piped or diffed; where it came from,
|
|
853
|
+
and which AGENTS.md files ride alongside it, goes to stderr so it never
|
|
854
|
+
contaminates that output.
|
|
847
855
|
"""
|
|
848
856
|
from hx.config import load_settings
|
|
849
|
-
from hx.core.context import resolve_system_prompt
|
|
857
|
+
from hx.core.context import load_instructions, resolve_system_prompt
|
|
850
858
|
|
|
851
859
|
try:
|
|
852
860
|
settings = load_settings(parsed.cwd, parsed.overrides)
|
|
853
861
|
resolved = resolve_system_prompt(settings.cwd, settings.prompt)
|
|
862
|
+
instructions = load_instructions(settings.cwd)
|
|
854
863
|
except Exception as exc:
|
|
855
864
|
return _report(exc)
|
|
856
865
|
|
|
@@ -858,6 +867,8 @@ def run_prompt_command(parsed: ParsedArgs) -> int:
|
|
|
858
867
|
print(f"[source] {resolved.source}", file=sys.stderr)
|
|
859
868
|
for append in resolved.appends:
|
|
860
869
|
print(f"[append] {append}", file=sys.stderr)
|
|
870
|
+
for loaded in instructions:
|
|
871
|
+
print(f"[instructions] {loaded.path}", file=sys.stderr)
|
|
861
872
|
return 0
|
|
862
873
|
|
|
863
874
|
|
|
@@ -10,7 +10,7 @@ Layout, in order::
|
|
|
10
10
|
[1] system prompt static for the session
|
|
11
11
|
[2] tool schemas deterministic sort: builtins, then mcp__* alphabetical
|
|
12
12
|
[3] skills index name + description only (progressive disclosure)
|
|
13
|
-
[4] project context
|
|
13
|
+
[4] project context cwd, git branch, top-level listing, ~/.hx/AGENTS.md, AGENTS.md
|
|
14
14
|
--- breakpoint A (static) ---
|
|
15
15
|
[5] conversation history
|
|
16
16
|
--- breakpoint B (rolling, before the last few turns) ---
|
|
@@ -238,11 +238,46 @@ def _message_text(message: Message) -> str:
|
|
|
238
238
|
return "\n".join(parts)
|
|
239
239
|
|
|
240
240
|
|
|
241
|
-
|
|
242
|
-
|
|
241
|
+
@dataclass(slots=True)
|
|
242
|
+
class Instructions:
|
|
243
|
+
"""One AGENTS.md in force: what it is, where it lives, what it says."""
|
|
244
|
+
|
|
245
|
+
heading: str
|
|
246
|
+
path: Path
|
|
247
|
+
text: str
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def load_instructions(cwd: Path) -> list[Instructions]:
|
|
251
|
+
"""Every AGENTS.md in force, least specific first.
|
|
252
|
+
|
|
253
|
+
``~/.hx/AGENTS.md`` follows the user into every project; the project's own
|
|
254
|
+
``AGENTS.md`` comes after it, so where the two disagree the more specific
|
|
255
|
+
one has the last word. A blank or unreadable file is skipped, and running
|
|
256
|
+
from inside ``$HX_HOME`` - where both paths name the same file - loads it once.
|
|
257
|
+
"""
|
|
258
|
+
from hx.paths import project_instructions_file, tilde, user_instructions_file
|
|
259
|
+
|
|
260
|
+
user = user_instructions_file()
|
|
261
|
+
found: list[Instructions] = []
|
|
262
|
+
seen: set[Path] = set()
|
|
263
|
+
for heading, path in (
|
|
264
|
+
(f"User instructions ({tilde(user)})", user),
|
|
265
|
+
("Project instructions (AGENTS.md)", project_instructions_file(cwd)),
|
|
266
|
+
):
|
|
267
|
+
body = _read_prompt_file(path)
|
|
268
|
+
if not body or path.resolve() in seen:
|
|
269
|
+
continue
|
|
270
|
+
seen.add(path.resolve())
|
|
271
|
+
found.append(Instructions(heading, path, body))
|
|
272
|
+
return found
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def build_project_context(cwd: Path, instructions: list[Instructions] | None = None) -> str:
|
|
276
|
+
"""Static per-session project preamble: cwd, git branch, listing, AGENTS.md contents.
|
|
243
277
|
|
|
244
278
|
Computed once at startup and then frozen - refreshing it mid-session would
|
|
245
|
-
invalidate the prefix.
|
|
279
|
+
invalidate the prefix. Pass ``instructions`` to fold in the AGENTS.md files
|
|
280
|
+
already loaded, so the caller can remember exactly which ones are in force.
|
|
246
281
|
"""
|
|
247
282
|
lines = [f"Working directory: {cwd}"]
|
|
248
283
|
|
|
@@ -256,9 +291,8 @@ def build_project_context(cwd: Path) -> str:
|
|
|
256
291
|
if entries:
|
|
257
292
|
lines.append("Top level: " + ", ".join(entries[:60]))
|
|
258
293
|
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
lines.append(f"\n# Project instructions (AGENTS.md)\n\n{agents_md.read_text()}")
|
|
294
|
+
for loaded in load_instructions(cwd) if instructions is None else instructions:
|
|
295
|
+
lines.append(f"\n# {loaded.heading}\n\n{loaded.text}")
|
|
262
296
|
|
|
263
297
|
return "\n".join(lines)
|
|
264
298
|
|
|
@@ -367,12 +401,12 @@ def resolve_system_prompt(cwd: Path, prompt: PromptSettings | None = None) -> Re
|
|
|
367
401
|
def _read_prompt_file(path: Path) -> str:
|
|
368
402
|
"""Contents of a prompt override file, or ``""`` when it is absent or empty.
|
|
369
403
|
|
|
370
|
-
An unreadable file is treated as absent: a
|
|
371
|
-
optional override must not stop the session from starting.
|
|
404
|
+
An unreadable file - no permission, or not UTF-8 - is treated as absent: a
|
|
405
|
+
problem with an optional override must not stop the session from starting.
|
|
372
406
|
"""
|
|
373
407
|
try:
|
|
374
|
-
return path.read_text().strip() if path.is_file() else ""
|
|
375
|
-
except OSError:
|
|
408
|
+
return path.read_text(encoding="utf-8").strip() if path.is_file() else ""
|
|
409
|
+
except (OSError, UnicodeDecodeError):
|
|
376
410
|
return ""
|
|
377
411
|
|
|
378
412
|
|
|
@@ -18,7 +18,7 @@ import time
|
|
|
18
18
|
from dataclasses import dataclass, replace
|
|
19
19
|
from typing import TYPE_CHECKING, Any, ClassVar
|
|
20
20
|
|
|
21
|
-
from hx.core.context import AssembledContext
|
|
21
|
+
from hx.core.context import AssembledContext, Instructions
|
|
22
22
|
from hx.core.events import (
|
|
23
23
|
CompactionFinished,
|
|
24
24
|
CompactionStarted,
|
|
@@ -103,6 +103,7 @@ class AgentLoop:
|
|
|
103
103
|
active_skills: ActiveSkills | None = None,
|
|
104
104
|
skills_index: str | None = None,
|
|
105
105
|
project_context: str | None = None,
|
|
106
|
+
instructions: list[Instructions] | None = None,
|
|
106
107
|
hooks: HookEngine | None = None,
|
|
107
108
|
) -> None:
|
|
108
109
|
self.provider = provider
|
|
@@ -118,6 +119,8 @@ class AgentLoop:
|
|
|
118
119
|
self.active_skills = active_skills
|
|
119
120
|
self.skills_index = skills_index
|
|
120
121
|
self.project_context = project_context
|
|
122
|
+
self.instructions = instructions or []
|
|
123
|
+
"""The AGENTS.md files folded into ``project_context``, least specific first."""
|
|
121
124
|
self.hooks = hooks
|
|
122
125
|
self._cancelled = False
|
|
123
126
|
self._turn_index = 0
|
|
@@ -30,6 +30,20 @@ def user_home() -> Path:
|
|
|
30
30
|
return Path(override).expanduser() if override else Path.home() / ".hx"
|
|
31
31
|
|
|
32
32
|
|
|
33
|
+
def tilde(path: str | Path) -> str:
|
|
34
|
+
"""``path`` with the home directory written as ``~``.
|
|
35
|
+
|
|
36
|
+
Compared as text, not resolved: a session records the path it resolved
|
|
37
|
+
when it was made, and the directory may be gone by the time it is drawn.
|
|
38
|
+
"""
|
|
39
|
+
text, home = str(path), str(Path.home())
|
|
40
|
+
if text == home:
|
|
41
|
+
return "~"
|
|
42
|
+
if text.startswith(home.rstrip("/") + "/"):
|
|
43
|
+
return "~" + text[len(home.rstrip("/")) :]
|
|
44
|
+
return text
|
|
45
|
+
|
|
46
|
+
|
|
33
47
|
def user_settings_file() -> Path:
|
|
34
48
|
return user_home() / "settings.json"
|
|
35
49
|
|
|
@@ -198,7 +212,28 @@ def project_system_prompt_append_file(cwd: Path | None = None) -> Path:
|
|
|
198
212
|
return project_dir(cwd) / "system-prompt-append.md"
|
|
199
213
|
|
|
200
214
|
|
|
215
|
+
def user_instructions_file() -> Path:
|
|
216
|
+
"""Your ``AGENTS.md``: instructions that follow you into every project."""
|
|
217
|
+
return user_home() / "AGENTS.md"
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def project_instructions_file(cwd: Path | None = None) -> Path:
|
|
221
|
+
"""The project's ``AGENTS.md``, at the root of the working directory."""
|
|
222
|
+
return (cwd or Path.cwd()) / "AGENTS.md"
|
|
223
|
+
|
|
224
|
+
|
|
201
225
|
def ensure_user_dirs() -> None:
|
|
202
|
-
"""Create the user-level
|
|
226
|
+
"""Create the user-level skeleton if it does not exist.
|
|
227
|
+
|
|
228
|
+
That includes an empty ``AGENTS.md``, so there is a file to find and fill
|
|
229
|
+
in; empty, it adds nothing to the prompt. An existing one - including a
|
|
230
|
+
symlink, dangling or not - is never touched, and failing to create it never
|
|
231
|
+
stops a session: the file is optional.
|
|
232
|
+
"""
|
|
203
233
|
for path in (user_home(), sessions_dir(), user_skills_dir(), user_agents_dir(), logs_dir()):
|
|
204
234
|
path.mkdir(parents=True, exist_ok=True)
|
|
235
|
+
try:
|
|
236
|
+
with user_instructions_file().open("x"):
|
|
237
|
+
pass
|
|
238
|
+
except OSError:
|
|
239
|
+
pass
|
|
@@ -507,7 +507,7 @@ async def cmd_title(ctx: CommandContext, args: str) -> None:
|
|
|
507
507
|
|
|
508
508
|
async def cmd_prompt(ctx: CommandContext, args: str) -> None:
|
|
509
509
|
"""``/prompt`` - the system prompt this session is actually running with."""
|
|
510
|
-
from hx.core.context import resolve_system_prompt
|
|
510
|
+
from hx.core.context import load_instructions, resolve_system_prompt
|
|
511
511
|
|
|
512
512
|
in_use = ctx.app.loop.context.system_prompt
|
|
513
513
|
lines = [in_use.rstrip()]
|
|
@@ -516,10 +516,15 @@ async def cmd_prompt(ctx: CommandContext, args: str) -> None:
|
|
|
516
516
|
lines.append(f"\nSource: {resolved.source}")
|
|
517
517
|
for append in resolved.appends:
|
|
518
518
|
lines.append(f"Appended: {append}")
|
|
519
|
+
in_force = ctx.app.loop.instructions
|
|
520
|
+
for loaded in in_force:
|
|
521
|
+
lines.append(f"Instructions: {loaded.path}")
|
|
522
|
+
# The prompt and AGENTS.md are read once at startup to keep the cache prefix
|
|
523
|
+
# stable, so an edit since then is real but not yet in force.
|
|
519
524
|
if resolved.text.strip() != in_use.strip():
|
|
520
|
-
# The prompt is read once at startup to keep the cache prefix stable, so
|
|
521
|
-
# an override edited since then is real but not yet in force.
|
|
522
525
|
lines.append("An override has changed on disk since startup; it applies next run.")
|
|
526
|
+
if load_instructions(ctx.settings.cwd) != in_force:
|
|
527
|
+
lines.append("An AGENTS.md has changed on disk since startup; it applies next run.")
|
|
523
528
|
ctx.app.notice("\n".join(lines))
|
|
524
529
|
|
|
525
530
|
|
|
@@ -741,8 +746,7 @@ async def cmd_permissions(ctx: CommandContext, args: str) -> None:
|
|
|
741
746
|
lines.append("")
|
|
742
747
|
lines.append('No rules configured. Add them under "permissions" in .hx/settings.json.')
|
|
743
748
|
|
|
744
|
-
from hx.paths import project_local_settings_file
|
|
745
|
-
from hx.tui.format import tilde
|
|
749
|
+
from hx.paths import project_local_settings_file, tilde
|
|
746
750
|
|
|
747
751
|
# Where, and why there: a grant is machine-local, so it is kept per project
|
|
748
752
|
# under the user's home instead of being dropped into the checkout.
|
|
@@ -11,7 +11,6 @@ from __future__ import annotations
|
|
|
11
11
|
|
|
12
12
|
import re
|
|
13
13
|
from collections.abc import Sequence
|
|
14
|
-
from pathlib import Path
|
|
15
14
|
|
|
16
15
|
from hx.term.sanitize import plain_text
|
|
17
16
|
from hx.term.width import cell_width, truncate_to_width
|
|
@@ -76,17 +75,3 @@ def one_line(text: str, width: int) -> str:
|
|
|
76
75
|
if cell_width(flat) <= width:
|
|
77
76
|
return flat
|
|
78
77
|
return truncate_to_width(flat, max(0, width - cell_width(ELLIPSIS))) + ELLIPSIS
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
def tilde(path: str | Path) -> str:
|
|
82
|
-
"""``path`` with the home directory written as ``~``.
|
|
83
|
-
|
|
84
|
-
Compared as text, not resolved: a session records the path it resolved
|
|
85
|
-
when it was made, and the directory may be gone by the time it is drawn.
|
|
86
|
-
"""
|
|
87
|
-
text, home = str(path), str(Path.home())
|
|
88
|
-
if text == home:
|
|
89
|
-
return "~"
|
|
90
|
-
if text.startswith(home.rstrip("/") + "/"):
|
|
91
|
-
return "~" + text[len(home.rstrip("/")) :]
|
|
92
|
-
return text
|
|
@@ -26,10 +26,10 @@ from hx.core.messages import (
|
|
|
26
26
|
)
|
|
27
27
|
from hx.core.usage import format_tokens
|
|
28
28
|
from hx.git import BranchWatcher
|
|
29
|
+
from hx.paths import tilde
|
|
29
30
|
from hx.term.loop import TuiRunner
|
|
30
31
|
from hx.term.modifiers import ShiftEnter
|
|
31
32
|
from hx.term.terminal import Terminal
|
|
32
|
-
from hx.tui.format import tilde
|
|
33
33
|
from hx.tui.renderers import ToolCall
|
|
34
34
|
from hx.tui.views.blocks import (
|
|
35
35
|
AssistantMessage,
|
|
@@ -16,9 +16,10 @@ import time
|
|
|
16
16
|
from typing import Any, ClassVar
|
|
17
17
|
|
|
18
18
|
from hx.core.usage import format_tokens
|
|
19
|
+
from hx.paths import tilde
|
|
19
20
|
from hx.term.component import Widget
|
|
20
21
|
from hx.term.primitives import Lines, Rule, Spacer, Text
|
|
21
|
-
from hx.tui.format import columns, one_line
|
|
22
|
+
from hx.tui.format import columns, one_line
|
|
22
23
|
from hx.tui.fuzzy import filter_items
|
|
23
24
|
from hx.tui.glyphs import CURRENT, CURSOR, GUTTER
|
|
24
25
|
from hx.tui.limits import LIST_MINIMUM, LIST_VISIBLE, RECORD_WIDTH
|
|
@@ -134,10 +134,19 @@ def test_mcp_add_list_remove(hx: HX) -> None:
|
|
|
134
134
|
|
|
135
135
|
|
|
136
136
|
def test_prompt_shows_project_instructions(hx: HX) -> None:
|
|
137
|
-
"""`hx prompt` prints the system prompt
|
|
137
|
+
"""`hx prompt` prints the system prompt and names every AGENTS.md in force, user's first;
|
|
138
|
+
provenance goes to stderr."""
|
|
139
|
+
(hx.hx_home / "AGENTS.md").write_text("Sign off every answer.\n")
|
|
138
140
|
(hx.project / "AGENTS.md").write_text("Always answer in haiku.\n")
|
|
139
141
|
result = hx.run("prompt", "--append-system-prompt", "Mind the tests.", check=True)
|
|
140
142
|
assert "[source]" in result.stderr
|
|
141
143
|
assert "[append]" in result.stderr
|
|
142
144
|
assert "[source]" not in result.stdout
|
|
143
145
|
assert "Mind the tests." in result.stdout
|
|
146
|
+
instructions = [
|
|
147
|
+
line for line in result.stderr.splitlines() if line.startswith("[instructions] ")
|
|
148
|
+
]
|
|
149
|
+
assert instructions == [
|
|
150
|
+
f"[instructions] {hx.hx_home / 'AGENTS.md'}",
|
|
151
|
+
f"[instructions] {hx.project / 'AGENTS.md'}",
|
|
152
|
+
]
|
|
@@ -107,6 +107,34 @@ def test_mode_command(hx: HX) -> None:
|
|
|
107
107
|
assert f"plan · {SANDBOX_STATUS}" in term.lines()[-2]
|
|
108
108
|
|
|
109
109
|
|
|
110
|
+
def test_prompt_command_names_the_agents_md_files(hx: HX) -> None:
|
|
111
|
+
"""/prompt shows the running system prompt, then every AGENTS.md in force, user's first."""
|
|
112
|
+
(hx.hx_home / "AGENTS.md").write_text("Sign off every answer.\n")
|
|
113
|
+
(hx.project / "AGENTS.md").write_text("Always answer in haiku.\n")
|
|
114
|
+
term = hx.tui(columns=250, rows=60)
|
|
115
|
+
term.submit("/prompt")
|
|
116
|
+
screen = term.wait_for("Instructions: " + str(hx.project / "AGENTS.md"))
|
|
117
|
+
term.snapshot("/prompt")
|
|
118
|
+
user = screen.index("Instructions: " + str(hx.hx_home / "AGENTS.md"))
|
|
119
|
+
assert screen.index("Source: built-in") < user < screen.index(str(hx.project / "AGENTS.md"))
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def test_prompt_command_names_the_agents_md_in_force_not_on_disk(hx: HX) -> None:
|
|
123
|
+
"""/prompt lists the AGENTS.md files the session started with; one written, edited or
|
|
124
|
+
emptied since is flagged as applying next run rather than claimed to be in force."""
|
|
125
|
+
(hx.project / "AGENTS.md").write_text("Always answer in haiku.\n")
|
|
126
|
+
term = hx.tui(columns=250, rows=60)
|
|
127
|
+
user_md = hx.hx_home / "AGENTS.md"
|
|
128
|
+
user_md.write_text("Sign off every answer.\n")
|
|
129
|
+
(hx.project / "AGENTS.md").write_text("")
|
|
130
|
+
term.submit("/prompt")
|
|
131
|
+
screen = term.wait_for("applies next run")
|
|
132
|
+
term.snapshot("/prompt after AGENTS.md changed on disk")
|
|
133
|
+
assert "Instructions: " + str(hx.project / "AGENTS.md") in screen
|
|
134
|
+
assert "Instructions: " + str(user_md) not in screen
|
|
135
|
+
assert "An AGENTS.md has changed on disk since startup; it applies next run." in screen
|
|
136
|
+
|
|
137
|
+
|
|
110
138
|
def test_title_command(hx: HX, stub: Stub) -> None:
|
|
111
139
|
"""/title shows the model's name for the session, and /title <text> renames it."""
|
|
112
140
|
stub.title = "Greeting exchange"
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import json
|
|
6
|
+
import os
|
|
6
7
|
import sys
|
|
7
8
|
from pathlib import Path
|
|
8
9
|
|
|
@@ -26,6 +27,84 @@ def test_agents_md_reaches_the_model(hx: HX, stub: Stub) -> None:
|
|
|
26
27
|
assert "Run tests with `make check`" in json.dumps(stub.requests[0].messages)
|
|
27
28
|
|
|
28
29
|
|
|
30
|
+
def test_user_agents_md_reaches_the_model_before_the_projects(hx: HX, stub: Stub) -> None:
|
|
31
|
+
"""~/.hx/AGENTS.md rides in every request, ahead of the project's AGENTS.md, byte-stable
|
|
32
|
+
across turns."""
|
|
33
|
+
write(hx.hx_home / "AGENTS.md", "USER-RULE: write commit subjects in the imperative.\n")
|
|
34
|
+
write(hx.project / "AGENTS.md", "PROJECT-RULE: run tests with `make check`.\n")
|
|
35
|
+
write(hx.project / "notes.txt", "hello\n")
|
|
36
|
+
stub.script(call("Read", file_path=str(hx.project / "notes.txt")), say("Read it."))
|
|
37
|
+
hx.run("-p", "read notes.txt", check=True)
|
|
38
|
+
|
|
39
|
+
first, second = stub.requests[0].system, stub.requests[1].system
|
|
40
|
+
assert first == second
|
|
41
|
+
user = first.index("USER-RULE: write commit subjects")
|
|
42
|
+
project = first.index("PROJECT-RULE: run tests")
|
|
43
|
+
assert user < project
|
|
44
|
+
assert first.index("# User instructions") < user < first.index("# Project instructions")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_user_agents_md_is_created_empty_and_never_clobbered(hx: HX, stub: Stub) -> None:
|
|
48
|
+
"""The first session leaves an empty ~/.hx/AGENTS.md to fill in, adding nothing to the
|
|
49
|
+
prompt; later sessions load what the user wrote and never rewrite it, even through a
|
|
50
|
+
symlink into a dotfiles repo."""
|
|
51
|
+
user_md = hx.hx_home / "AGENTS.md"
|
|
52
|
+
assert not user_md.exists()
|
|
53
|
+
stub.script(say("Fine."))
|
|
54
|
+
hx.run("-p", "hi", check=True)
|
|
55
|
+
assert user_md.is_file() and user_md.read_text() == ""
|
|
56
|
+
assert "# User instructions" not in stub.requests[0].system
|
|
57
|
+
|
|
58
|
+
user_md.write_text("USER-RULE: sign off every answer.\n")
|
|
59
|
+
stub.script(say("Fine."))
|
|
60
|
+
hx.run("-p", "hi", check=True)
|
|
61
|
+
assert user_md.read_text() == "USER-RULE: sign off every answer.\n"
|
|
62
|
+
assert "USER-RULE: sign off every answer." in stub.requests[1].system
|
|
63
|
+
|
|
64
|
+
dotfiles = write(hx.home / "dotfiles" / "AGENTS.md", "DOTFILES-RULE: tabs, not spaces.\n")
|
|
65
|
+
user_md.unlink()
|
|
66
|
+
user_md.symlink_to(dotfiles)
|
|
67
|
+
stub.script(say("Fine."))
|
|
68
|
+
hx.run("-p", "hi", check=True)
|
|
69
|
+
assert user_md.is_symlink()
|
|
70
|
+
assert dotfiles.read_text() == "DOTFILES-RULE: tabs, not spaces.\n"
|
|
71
|
+
assert "DOTFILES-RULE: tabs, not spaces." in stub.requests[2].system
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_blank_unreadable_and_shared_agents_md_are_harmless(hx: HX, stub: Stub) -> None:
|
|
75
|
+
"""A blank AGENTS.md adds no empty heading, one that is not UTF-8 or not readable does not
|
|
76
|
+
stop the session, and running from inside $HX_HOME does not load the same file twice."""
|
|
77
|
+
write(hx.project / "AGENTS.md", " \n\n")
|
|
78
|
+
user_md = hx.hx_home / "AGENTS.md"
|
|
79
|
+
# Saved from a Latin-1 editor: "café" is not valid UTF-8.
|
|
80
|
+
user_md.write_bytes(b"NEVER-SEEN caf\xe9\n")
|
|
81
|
+
stub.script(say("Fine."))
|
|
82
|
+
hx.run("-p", "hi", check=True)
|
|
83
|
+
system = stub.requests[-1].system
|
|
84
|
+
assert "# User instructions" not in system
|
|
85
|
+
assert "# Project instructions" not in system
|
|
86
|
+
assert "NEVER-SEEN" not in system
|
|
87
|
+
|
|
88
|
+
# Root reads through any mode bits, so there is nothing to test there.
|
|
89
|
+
if os.geteuid() != 0:
|
|
90
|
+
user_md.write_text("NEVER-SEEN\n")
|
|
91
|
+
user_md.chmod(0o000)
|
|
92
|
+
try:
|
|
93
|
+
stub.script(say("Fine."))
|
|
94
|
+
hx.run("-p", "hi", check=True)
|
|
95
|
+
assert "NEVER-SEEN" not in stub.requests[-1].system
|
|
96
|
+
finally:
|
|
97
|
+
user_md.chmod(0o644)
|
|
98
|
+
|
|
99
|
+
user_md.write_text("SHARED-RULE: once only.\n")
|
|
100
|
+
stub.script(say("Fine."))
|
|
101
|
+
hx.run("-p", "hi", cwd=hx.hx_home, check=True)
|
|
102
|
+
system = stub.requests[-1].system
|
|
103
|
+
assert system.count("SHARED-RULE: once only.") == 1
|
|
104
|
+
assert "# User instructions" in system
|
|
105
|
+
assert "# Project instructions" not in system
|
|
106
|
+
|
|
107
|
+
|
|
29
108
|
def test_skill_is_indexed_then_loaded(hx: HX, stub: Stub) -> None:
|
|
30
109
|
"""Only a skill's name and description are sent up front; calling Skill loads its body."""
|
|
31
110
|
write(
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|