hx-cli 0.2.9__tar.gz → 0.2.10__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.9 → hx_cli-0.2.10}/CHANGELOG.md +18 -1
- {hx_cli-0.2.9 → hx_cli-0.2.10}/PKG-INFO +9 -2
- {hx_cli-0.2.9 → hx_cli-0.2.10}/README.md +8 -1
- {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/__init__.py +1 -1
- {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/agents/subagent.py +6 -0
- {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/cli.py +3 -1
- {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/core/context.py +33 -12
- {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/paths.py +44 -3
- {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/tui/commands.py +8 -3
- {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/tui/runtime.py +11 -13
- {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/tui/views/pickers.py +21 -2
- {hx_cli-0.2.9 → hx_cli-0.2.10}/tests/e2e/test_commands.py +73 -0
- {hx_cli-0.2.9 → hx_cli-0.2.10}/tests/e2e/test_extend.py +50 -1
- {hx_cli-0.2.9 → hx_cli-0.2.10}/.github/workflows/ci.yml +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.10}/.gitignore +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.10}/examples/hooks/check.sh +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.10}/install.sh +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.10}/pyproject.toml +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/agents/__init__.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/agents/definitions.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/auth/__init__.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/auth/oauth/__init__.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/auth/oauth/browser.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/auth/oauth/callback.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/auth/oauth/codex.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/auth/oauth/devin.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/auth/oauth/pkce.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/auth/resolve.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/auth/store.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/config.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/core/__init__.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/core/checkpoints.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/core/compaction.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/core/events.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/core/images.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/core/lateinject.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/core/loop.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/core/messages.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/core/session.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/core/title.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/core/usage.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/docs.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/frontmatter.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/git.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/hooks/__init__.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/hooks/engine.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/hooks/spec.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/keys.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/mcp/__init__.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/mcp/client.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/mcp/manager.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/mcp/oauth.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/net.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/permissions/__init__.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/permissions/engine.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/permissions/parser.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/permissions/sandbox.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/providers/__init__.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/providers/base.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/providers/codex.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/providers/codex_catalogue.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/providers/devin.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/providers/devin_catalogue.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/providers/devin_wire.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/providers/models.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/providers/openrouter.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/providers/protowire.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/providers/registry.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/providers/responses_codec.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/skills/__init__.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/skills/loader.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/skills/runtime.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/term/__init__.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/term/ansi.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/term/buffer.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/term/component.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/term/demo.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/term/editor.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/term/keydecode.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/term/loop.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/term/markdown.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/term/modifiers.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/term/primitives.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/term/sanitize.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/term/screen.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/term/syntax.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/term/terminal.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/term/undo.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/term/width.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/term/word_nav.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/tools/__init__.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/tools/anchors.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/tools/base.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/tools/bash.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/tools/edit.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/tools/glob.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/tools/grep.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/tools/output.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/tools/read.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/tools/registry.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/tools/symbols.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/tools/task.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/tools/todo.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/tools/websearch.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/tools/write.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/trace.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/tui/__init__.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/tui/app.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/tui/clipboard.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/tui/format.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/tui/fuzzy.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/tui/glyphs.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/tui/killring.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/tui/limits.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/tui/paint.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/tui/renderers.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/tui/roles.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/tui/theme.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/tui/theme_json.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/tui/themes/ansi.json +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/tui/themes/dark.json +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/tui/themes/light.json +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/tui/views/__init__.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/tui/views/blocks.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/tui/views/dialog.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/tui/views/login.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/tui/views/permission.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/tui/views/prompt.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/tui/views/status.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/tui/views/transcript.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.10}/tests/__init__.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.10}/tests/conftest.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.10}/tests/e2e/__init__.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.10}/tests/e2e/conftest.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.10}/tests/e2e/fixtures/echo_server.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.10}/tests/e2e/report.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.10}/tests/e2e/stub.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.10}/tests/e2e/terminal.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.10}/tests/e2e/test_chat.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.10}/tests/e2e/test_cli.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.10}/tests/e2e/test_input.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.10}/tests/e2e/test_print.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.10}/tests/e2e/test_tools.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.10}/tests/test_live.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.10}/tests/test_live_codex.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.10}/tests/test_live_devin.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.10}/tests/test_live_tavily.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.10}/uv.lock +0 -0
|
@@ -28,6 +28,22 @@ the project follows [semantic versioning](https://semver.org/).
|
|
|
28
28
|
|
|
29
29
|
---
|
|
30
30
|
|
|
31
|
+
## [0.2.10] - 2026-09-29
|
|
32
|
+
|
|
33
|
+
### Fixed
|
|
34
|
+
|
|
35
|
+
- `AGENTS.md` at the repository root now applies when HX is started in a
|
|
36
|
+
subdirectory, followed by each `AGENTS.md` down to that directory.
|
|
37
|
+
- Subagents started with `Task` now follow `~/.hx/AGENTS.md` and the project's
|
|
38
|
+
`AGENTS.md`, as the main session does.
|
|
39
|
+
- A saved `/effort` level shows on the status bar from the first screen of every
|
|
40
|
+
new session, not only after `/effort` is run again.
|
|
41
|
+
- `ctrl+l` switches the model and saves it for later sessions, as `/model` does;
|
|
42
|
+
it used to relabel the status bar only.
|
|
43
|
+
- `/model`, `ctrl+l` and `/effort` open with the cursor on the choice in force.
|
|
44
|
+
|
|
45
|
+
---
|
|
46
|
+
|
|
31
47
|
## [0.2.9] - 2026-09-27
|
|
32
48
|
|
|
33
49
|
### Added
|
|
@@ -742,7 +758,8 @@ First release, published to PyPI as [`hx-cli`](https://pypi.org/project/hx-cli/)
|
|
|
742
758
|
- `/configure` and `hx auth` for the OpenRouter key, `hx upgrade` for
|
|
743
759
|
self-update, and `install.sh` bootstrapping uv with a pinned Python.
|
|
744
760
|
|
|
745
|
-
[Unreleased]: https://github.com/aletisunil/hx/compare/v0.2.
|
|
761
|
+
[Unreleased]: https://github.com/aletisunil/hx/compare/v0.2.10...HEAD
|
|
762
|
+
[0.2.10]: https://github.com/aletisunil/hx/compare/v0.2.9...v0.2.10
|
|
746
763
|
[0.2.9]: https://github.com/aletisunil/hx/compare/v0.2.8...v0.2.9
|
|
747
764
|
[0.2.8]: https://github.com/aletisunil/hx/compare/v0.2.7...v0.2.8
|
|
748
765
|
[0.2.7]: https://github.com/aletisunil/hx/compare/v0.2.6...v0.2.7
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: hx-cli
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.10
|
|
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
|
|
@@ -762,7 +762,7 @@ files load here unchanged. `src/hx/tui/themes/dark.json` is the reference.
|
|
|
762
762
|
| `./.hx/mcp.json` | project MCP servers |
|
|
763
763
|
| `./.hx/system-prompt.md`, `./.hx/system-prompt-append.md` | project prompt overrides |
|
|
764
764
|
| `./.hx/skills/`, `./.hx/agents/` | project skills and agents |
|
|
765
|
-
|
|
|
765
|
+
| `AGENTS.md` | project instructions: the repository root's, then each one down to where HX started |
|
|
766
766
|
|
|
767
767
|
`AGENTS.md` is the place for things a newcomer would get wrong: how to run the
|
|
768
768
|
tests, conventions, what not to touch. `/init` writes a first draft. It is
|
|
@@ -774,6 +774,13 @@ loaded first and the project's `AGENTS.md` after it, so where the two disagree
|
|
|
774
774
|
the project has the last word. The first session creates it empty, ready to
|
|
775
775
|
fill in; empty, it adds nothing. `hx prompt` and `/prompt` list each one in force.
|
|
776
776
|
|
|
777
|
+
Started below the repository root - in `services/billing`, say - HX still loads
|
|
778
|
+
the root's `AGENTS.md`, then every `AGENTS.md` on the way down to that directory,
|
|
779
|
+
the nearest last, so a package can refine the repository's rules. A submodule
|
|
780
|
+
counts as part of the repository around it. Nothing above the repository root is
|
|
781
|
+
read; outside a repository only the current directory is.
|
|
782
|
+
Subagents run under the same files as the session that started them.
|
|
783
|
+
|
|
777
784
|
---
|
|
778
785
|
|
|
779
786
|
## Safety
|
|
@@ -722,7 +722,7 @@ files load here unchanged. `src/hx/tui/themes/dark.json` is the reference.
|
|
|
722
722
|
| `./.hx/mcp.json` | project MCP servers |
|
|
723
723
|
| `./.hx/system-prompt.md`, `./.hx/system-prompt-append.md` | project prompt overrides |
|
|
724
724
|
| `./.hx/skills/`, `./.hx/agents/` | project skills and agents |
|
|
725
|
-
|
|
|
725
|
+
| `AGENTS.md` | project instructions: the repository root's, then each one down to where HX started |
|
|
726
726
|
|
|
727
727
|
`AGENTS.md` is the place for things a newcomer would get wrong: how to run the
|
|
728
728
|
tests, conventions, what not to touch. `/init` writes a first draft. It is
|
|
@@ -734,6 +734,13 @@ loaded first and the project's `AGENTS.md` after it, so where the two disagree
|
|
|
734
734
|
the project has the last word. The first session creates it empty, ready to
|
|
735
735
|
fill in; empty, it adds nothing. `hx prompt` and `/prompt` list each one in force.
|
|
736
736
|
|
|
737
|
+
Started below the repository root - in `services/billing`, say - HX still loads
|
|
738
|
+
the root's `AGENTS.md`, then every `AGENTS.md` on the way down to that directory,
|
|
739
|
+
the nearest last, so a package can refine the repository's rules. A submodule
|
|
740
|
+
counts as part of the repository around it. Nothing above the repository root is
|
|
741
|
+
read; outside a repository only the current directory is.
|
|
742
|
+
Subagents run under the same files as the session that started them.
|
|
743
|
+
|
|
737
744
|
---
|
|
738
745
|
|
|
739
746
|
## Safety
|
|
@@ -59,6 +59,7 @@ class SubagentRunner:
|
|
|
59
59
|
parent_session_id: str | None = None,
|
|
60
60
|
parent_usage: object | None = None,
|
|
61
61
|
hooks: HookEngine | None = None,
|
|
62
|
+
project_context: str | None = None,
|
|
62
63
|
) -> None:
|
|
63
64
|
self.definitions = definitions
|
|
64
65
|
self.provider = provider
|
|
@@ -72,6 +73,10 @@ class SubagentRunner:
|
|
|
72
73
|
self.hooks = hooks
|
|
73
74
|
"""Shared with the parent: a hook that guards Bash must guard a subagent's
|
|
74
75
|
Bash too, or the guard is one delegation away from being bypassed."""
|
|
76
|
+
self.project_context = project_context
|
|
77
|
+
"""The parent's project preamble, AGENTS.md included. A subagent edits the
|
|
78
|
+
same project under the same rules; without it "never add unit tests" is
|
|
79
|
+
one delegation away from being ignored."""
|
|
75
80
|
self._active: dict[str, str] = {}
|
|
76
81
|
|
|
77
82
|
async def run(self, agent_type: str, prompt: str, description: str) -> SubagentResult:
|
|
@@ -160,6 +165,7 @@ class SubagentRunner:
|
|
|
160
165
|
bus=self.bus,
|
|
161
166
|
settings=self.settings,
|
|
162
167
|
model_info=self.models.get_or_default(model) if self.models else None,
|
|
168
|
+
project_context=self.project_context,
|
|
163
169
|
hooks=self.hooks,
|
|
164
170
|
)
|
|
165
171
|
loop.origin = f"{definition.name} subagent"
|
|
@@ -436,6 +436,7 @@ def build_runtime(parsed: ParsedArgs, *, resume: str | None = None) -> Runtime:
|
|
|
436
436
|
keep_recent_turns=settings.context.keep_recent_turns,
|
|
437
437
|
)
|
|
438
438
|
instructions = load_instructions(settings.cwd)
|
|
439
|
+
project_context = build_project_context(settings.cwd, instructions)
|
|
439
440
|
|
|
440
441
|
checkpoints = CheckpointStore(session, session_checkpoints_dir(session.meta.session_id))
|
|
441
442
|
tools = build_default_registry(shell, jobs, tracker, todos, bus_holder, auth, checkpoints)
|
|
@@ -464,6 +465,7 @@ def build_runtime(parsed: ParsedArgs, *, resume: str | None = None) -> Runtime:
|
|
|
464
465
|
parent_session_id=session.meta.session_id,
|
|
465
466
|
parent_usage=session.usage,
|
|
466
467
|
hooks=hooks,
|
|
468
|
+
project_context=project_context,
|
|
467
469
|
)
|
|
468
470
|
tools.register(TaskTool(subagents))
|
|
469
471
|
|
|
@@ -484,7 +486,7 @@ def build_runtime(parsed: ParsedArgs, *, resume: str | None = None) -> Runtime:
|
|
|
484
486
|
settings=settings,
|
|
485
487
|
model_info=model_info,
|
|
486
488
|
skills_index=build_index(list(skills.values())) or None,
|
|
487
|
-
project_context=
|
|
489
|
+
project_context=project_context,
|
|
488
490
|
instructions=instructions,
|
|
489
491
|
hooks=hooks,
|
|
490
492
|
)
|
|
@@ -10,7 +10,8 @@ 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 cwd, git branch, top-level listing, ~/.hx/AGENTS.md,
|
|
13
|
+
[4] project context cwd, git branch, top-level listing, ~/.hx/AGENTS.md,
|
|
14
|
+
each AGENTS.md from the repository root down to cwd
|
|
14
15
|
--- breakpoint A (static) ---
|
|
15
16
|
[5] conversation history
|
|
16
17
|
--- breakpoint B (rolling, before the last few turns) ---
|
|
@@ -250,20 +251,26 @@ class Instructions:
|
|
|
250
251
|
def load_instructions(cwd: Path) -> list[Instructions]:
|
|
251
252
|
"""Every AGENTS.md in force, least specific first.
|
|
252
253
|
|
|
253
|
-
``~/.hx/AGENTS.md`` follows the user into every project;
|
|
254
|
-
``AGENTS.md``
|
|
255
|
-
one has the last word. A blank or
|
|
256
|
-
|
|
254
|
+
``~/.hx/AGENTS.md`` follows the user into every project; then each
|
|
255
|
+
``AGENTS.md`` from the repository root down to ``cwd``. Where two disagree
|
|
256
|
+
the more specific one comes later and has the last word. A blank or
|
|
257
|
+
unreadable file is skipped, and a path reached twice - running from inside
|
|
258
|
+
``$HX_HOME``, say - is loaded once.
|
|
257
259
|
"""
|
|
258
|
-
from hx.paths import
|
|
260
|
+
from hx.paths import project_instructions_files, project_root, tilde, user_instructions_file
|
|
259
261
|
|
|
260
262
|
user = user_instructions_file()
|
|
263
|
+
here = cwd.resolve()
|
|
264
|
+
root = project_root(here)
|
|
265
|
+
base = root or here
|
|
266
|
+
candidates = [(f"User instructions ({tilde(user)})", user)]
|
|
267
|
+
for path in project_instructions_files(here, root):
|
|
268
|
+
where = path.relative_to(base).as_posix()
|
|
269
|
+
candidates.append((f"Project instructions ({where})", path))
|
|
270
|
+
|
|
261
271
|
found: list[Instructions] = []
|
|
262
272
|
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
|
-
):
|
|
273
|
+
for heading, path in candidates:
|
|
267
274
|
body = _read_prompt_file(path)
|
|
268
275
|
if not body or path.resolve() in seen:
|
|
269
276
|
continue
|
|
@@ -272,6 +279,17 @@ def load_instructions(cwd: Path) -> list[Instructions]:
|
|
|
272
279
|
return found
|
|
273
280
|
|
|
274
281
|
|
|
282
|
+
INSTRUCTIONS_PREAMBLE = (
|
|
283
|
+
"The sections below are AGENTS.md files: standing instructions from the user "
|
|
284
|
+
"and this project, already loaded in full - there is no need to read them "
|
|
285
|
+
"again. Follow them over your own defaults and habits. Where two disagree, "
|
|
286
|
+
"the later, more specific one wins."
|
|
287
|
+
)
|
|
288
|
+
"""Said once, ahead of the files. Without it a rule like "no unit tests" reads
|
|
289
|
+
as background next to a request to make something "well covered", and the
|
|
290
|
+
model re-reads the file it was already given to find out whether it counts."""
|
|
291
|
+
|
|
292
|
+
|
|
275
293
|
def build_project_context(cwd: Path, instructions: list[Instructions] | None = None) -> str:
|
|
276
294
|
"""Static per-session project preamble: cwd, git branch, listing, AGENTS.md contents.
|
|
277
295
|
|
|
@@ -291,8 +309,11 @@ def build_project_context(cwd: Path, instructions: list[Instructions] | None = N
|
|
|
291
309
|
if entries:
|
|
292
310
|
lines.append("Top level: " + ", ".join(entries[:60]))
|
|
293
311
|
|
|
294
|
-
|
|
295
|
-
|
|
312
|
+
loaded = load_instructions(cwd) if instructions is None else instructions
|
|
313
|
+
if loaded:
|
|
314
|
+
lines.append(f"\n{INSTRUCTIONS_PREAMBLE}")
|
|
315
|
+
for entry in loaded:
|
|
316
|
+
lines.append(f"\n# {entry.heading}\n\n{entry.text}")
|
|
296
317
|
|
|
297
318
|
return "\n".join(lines)
|
|
298
319
|
|
|
@@ -217,9 +217,50 @@ def user_instructions_file() -> Path:
|
|
|
217
217
|
return user_home() / "AGENTS.md"
|
|
218
218
|
|
|
219
219
|
|
|
220
|
-
def
|
|
221
|
-
"""The
|
|
222
|
-
|
|
220
|
+
def project_root(cwd: Path | None = None) -> Path | None:
|
|
221
|
+
"""The repository ``cwd`` sits in: the nearest directory holding ``.git``.
|
|
222
|
+
|
|
223
|
+
``.git`` is a directory in a clone and a file in a worktree or submodule.
|
|
224
|
+
A submodule is part of the repository around it, so the walk carries on
|
|
225
|
+
past one to the superproject. ``None`` outside a repository.
|
|
226
|
+
"""
|
|
227
|
+
start = (cwd or Path.cwd()).resolve()
|
|
228
|
+
submodule: Path | None = None
|
|
229
|
+
for directory in (start, *start.parents):
|
|
230
|
+
marker = directory / ".git"
|
|
231
|
+
if marker.is_dir() or (marker.is_file() and not _is_submodule(marker)):
|
|
232
|
+
return directory
|
|
233
|
+
if marker.is_file() and submodule is None:
|
|
234
|
+
submodule = directory
|
|
235
|
+
return submodule
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _is_submodule(marker: Path) -> bool:
|
|
239
|
+
"""Whether a ``.git`` file points into a superproject's ``.git/modules``."""
|
|
240
|
+
try:
|
|
241
|
+
text = marker.read_text(encoding="utf-8", errors="replace")
|
|
242
|
+
except OSError:
|
|
243
|
+
return False
|
|
244
|
+
return text.startswith("gitdir:") and "/modules/" in text.replace("\\", "/")
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def project_instructions_files(cwd: Path, root: Path | None) -> list[Path]:
|
|
248
|
+
"""Every ``AGENTS.md`` that governs ``cwd``, least specific first.
|
|
249
|
+
|
|
250
|
+
From the repository root down to ``cwd``, one candidate per directory, so
|
|
251
|
+
starting HX in ``src/app`` still finds the root's file - and ``src/app``'s
|
|
252
|
+
own, which comes last and so has the last word. Outside a repository only
|
|
253
|
+
``cwd`` itself is looked at: walking up from an arbitrary directory would
|
|
254
|
+
pick up whatever happens to sit in its parents.
|
|
255
|
+
|
|
256
|
+
``root`` is :func:`project_root` of ``cwd``, passed in because the caller
|
|
257
|
+
needs it too and finding it walks the file system.
|
|
258
|
+
"""
|
|
259
|
+
here = cwd.resolve()
|
|
260
|
+
if root is None:
|
|
261
|
+
return [here / "AGENTS.md"]
|
|
262
|
+
chain = [here, *here.parents]
|
|
263
|
+
return [directory / "AGENTS.md" for directory in reversed(chain[: chain.index(root) + 1])]
|
|
223
264
|
|
|
224
265
|
|
|
225
266
|
def ensure_user_dirs() -> None:
|
|
@@ -107,7 +107,10 @@ async def cmd_model(ctx: CommandContext, args: str) -> None:
|
|
|
107
107
|
from hx.providers.models import ModelRegistry
|
|
108
108
|
from hx.tui.views.pickers import ModelPicker
|
|
109
109
|
|
|
110
|
-
registry: ModelRegistry = ctx.app.models
|
|
110
|
+
registry: ModelRegistry | None = ctx.app.models
|
|
111
|
+
if registry is None:
|
|
112
|
+
ctx.app.notice("No model registry is loaded.", "warning")
|
|
113
|
+
return
|
|
111
114
|
models = _reachable(ctx, registry.all())
|
|
112
115
|
if not models:
|
|
113
116
|
# A recorded failure replaces the remedy rather than trailing it:
|
|
@@ -780,7 +783,9 @@ async def cmd_mode(ctx: CommandContext, args: str) -> None:
|
|
|
780
783
|
|
|
781
784
|
|
|
782
785
|
INIT_PROMPT = """\
|
|
783
|
-
Write an AGENTS.md
|
|
786
|
+
Write an AGENTS.md in {directory}, the directory HX was started in. Any AGENTS.md
|
|
787
|
+
between the repository root and there is loaded before it, so leave out what
|
|
788
|
+
those already say.
|
|
784
789
|
|
|
785
790
|
Read enough of the codebase to be accurate. Cover: what the project is, how to
|
|
786
791
|
build, test and lint it, the layout of the source tree, and any conventions a
|
|
@@ -792,7 +797,7 @@ If AGENTS.md already exists, improve it rather than replacing it wholesale."""
|
|
|
792
797
|
|
|
793
798
|
async def cmd_init(ctx: CommandContext, args: str) -> None:
|
|
794
799
|
"""``/init`` - generate an AGENTS.md describing this project."""
|
|
795
|
-
await ctx.app.submit_to_model(INIT_PROMPT)
|
|
800
|
+
await ctx.app.submit_to_model(INIT_PROMPT.format(directory=ctx.settings.cwd))
|
|
796
801
|
|
|
797
802
|
|
|
798
803
|
async def cmd_configure(ctx: CommandContext, args: str) -> None:
|
|
@@ -785,17 +785,9 @@ class HXSession:
|
|
|
785
785
|
await self._run_command(f"/{chosen}")
|
|
786
786
|
|
|
787
787
|
async def _open_models(self) -> None:
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
if registry is None:
|
|
792
|
-
self._notice("no model registry is loaded", "warning")
|
|
793
|
-
return
|
|
794
|
-
current = getattr(getattr(self.loop, "model_info", None), "id", "") or ""
|
|
795
|
-
chosen = await self.ask(ModelPicker(list(registry.all()), current))
|
|
796
|
-
if chosen:
|
|
797
|
-
self.view.dock.status.set_model(chosen)
|
|
798
|
-
self._notice(f"model set to {chosen}")
|
|
788
|
+
# The same path as `/model`: a picker that only relabelled the status
|
|
789
|
+
# bar left the session on the old model and the choice unsaved.
|
|
790
|
+
await self._run_command("/model")
|
|
799
791
|
|
|
800
792
|
def _commands(self) -> list[Any]:
|
|
801
793
|
return list(self.commands.all()) if self.commands is not None else []
|
|
@@ -963,7 +955,8 @@ class HXSession:
|
|
|
963
955
|
asyncio.create_task(self._open_commands()) # noqa: RUF006
|
|
964
956
|
return
|
|
965
957
|
if name == "ctrl+l":
|
|
966
|
-
|
|
958
|
+
# Tracked like a typed `/model`, so an interrupt cancels it too.
|
|
959
|
+
self._side = asyncio.create_task(self._open_models())
|
|
967
960
|
return
|
|
968
961
|
# Only here, on the way to the prompt: nothing else binds shift+enter,
|
|
969
962
|
# so an approval or a picker has to keep the enter it was sent. Not
|
|
@@ -1334,8 +1327,13 @@ class HXSession:
|
|
|
1334
1327
|
)
|
|
1335
1328
|
model = getattr(self.loop, "model_info", None)
|
|
1336
1329
|
if model is not None:
|
|
1337
|
-
|
|
1330
|
+
model_id = getattr(model, "id", "") or ""
|
|
1331
|
+
status.set_model(model_id, subscription=bool(getattr(model, "is_subscription", False)))
|
|
1338
1332
|
status.set_context(0, getattr(model, "context_window", 0) or 0)
|
|
1333
|
+
# The saved effort is in force from the first turn; a bar that only
|
|
1334
|
+
# shows it once /effort is run again reads as the choice not sticking.
|
|
1335
|
+
if self.models is not None:
|
|
1336
|
+
status.set_effort(self.models.displayed_effort(model_id))
|
|
1339
1337
|
|
|
1340
1338
|
|
|
1341
1339
|
async def run_session(
|
|
@@ -61,6 +61,13 @@ class Picker(Widget, Framed):
|
|
|
61
61
|
two-hundred model catalogue, with two thirds of a tall window empty
|
|
62
62
|
underneath."""
|
|
63
63
|
self._refresh()
|
|
64
|
+
# Open on what is already in force, so enter alone keeps it and the
|
|
65
|
+
# arrow and the ✓ do not point at different rows.
|
|
66
|
+
current = self.current_value()
|
|
67
|
+
self.selected = next(
|
|
68
|
+
(index for index, (value, _cells) in enumerate(self._rows) if value == current),
|
|
69
|
+
0,
|
|
70
|
+
)
|
|
64
71
|
|
|
65
72
|
# -- the room this has to work with ------------------------------------
|
|
66
73
|
|
|
@@ -260,7 +267,11 @@ class ModelPicker(Picker):
|
|
|
260
267
|
# The same matcher `/model <query>` uses, so typing here narrows
|
|
261
268
|
# exactly the way typing there does.
|
|
262
269
|
matches = match_models(self.models, query)
|
|
263
|
-
|
|
270
|
+
shown = matches[: self.LIMIT]
|
|
271
|
+
# The model in force stays listed however far down it sorts, or the
|
|
272
|
+
# picker opens on some other row with no ✓ anywhere.
|
|
273
|
+
shown += [model for model in matches[self.LIMIT :] if model.id == self.current]
|
|
274
|
+
return [(model.id, self._cells(model)) for model in shown]
|
|
264
275
|
|
|
265
276
|
def _cells(self, model: Any) -> list[str]:
|
|
266
277
|
mode = str(model.cache_mode)
|
|
@@ -310,7 +321,15 @@ class EffortPicker(Picker):
|
|
|
310
321
|
super().__init__()
|
|
311
322
|
|
|
312
323
|
def current_value(self) -> str | None:
|
|
313
|
-
|
|
324
|
+
if self.current is None:
|
|
325
|
+
return DEFAULT_EFFORT_ROW
|
|
326
|
+
if self.current in self.info.reasoning_levels:
|
|
327
|
+
return self.current
|
|
328
|
+
# Clamped for this model: the level it actually runs at is the one in
|
|
329
|
+
# force, and a row that matches nothing would open on "default".
|
|
330
|
+
from hx.providers.codex_catalogue import resolve_effort
|
|
331
|
+
|
|
332
|
+
return resolve_effort(self.current, self.info)
|
|
314
333
|
|
|
315
334
|
def rows(self, query: str) -> list[tuple[str, list[str]]]:
|
|
316
335
|
default_level = self.info.default_reasoning_level or "the model's default"
|
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import json
|
|
5
6
|
import re
|
|
7
|
+
import time
|
|
6
8
|
|
|
7
9
|
from tests.e2e.conftest import HX, SANDBOX, SANDBOX_STATUS
|
|
8
10
|
from tests.e2e.stub import GPT5, Stub, call, say
|
|
@@ -86,6 +88,77 @@ def test_model_picker_switches_the_model(hx: HX, stub: Stub) -> None:
|
|
|
86
88
|
assert stub.requests[0].model == GPT5
|
|
87
89
|
|
|
88
90
|
|
|
91
|
+
def test_ctrl_l_switches_the_model_and_remembers_it(hx: HX, stub: Stub) -> None:
|
|
92
|
+
"""ctrl+l opens the model picker on the model in force; choosing another routes the next
|
|
93
|
+
turn to it and saves it for the next session, exactly as /model does."""
|
|
94
|
+
stub.script(say("Answered by GPT-5."))
|
|
95
|
+
term = hx.tui()
|
|
96
|
+
term.press("ctrl+l")
|
|
97
|
+
screen = term.wait_for("Select model")
|
|
98
|
+
term.snapshot("ctrl+l model picker")
|
|
99
|
+
assert re.search(r"→ ✓ anthropic/claude-sonnet-4\.5\s", screen), "opens on the current model"
|
|
100
|
+
|
|
101
|
+
term.type("gpt")
|
|
102
|
+
term.wait_for(lambda s: "deepseek" not in s)
|
|
103
|
+
term.press("enter")
|
|
104
|
+
term.wait_for("Model set to openai/gpt-5")
|
|
105
|
+
term.submit("who are you?")
|
|
106
|
+
term.wait_for("Answered by GPT-5.")
|
|
107
|
+
assert stub.requests[0].model == GPT5
|
|
108
|
+
saved = json.loads((hx.hx_home / "settings.json").read_text())
|
|
109
|
+
assert saved["models"]["model"] == GPT5
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def test_effort_carries_into_every_new_session(hx: HX) -> None:
|
|
113
|
+
"""A saved reasoning effort is in force - and on the status bar - from the first screen of
|
|
114
|
+
a new session, through /clear and a restart; the picker opens on it, and a new pick
|
|
115
|
+
replaces it for the sessions after."""
|
|
116
|
+
codex = "openai-codex/gpt-5.6-terra"
|
|
117
|
+
(hx.hx_home / "auth.json").write_text(
|
|
118
|
+
json.dumps(
|
|
119
|
+
{
|
|
120
|
+
"openai-codex": {
|
|
121
|
+
"type": "oauth",
|
|
122
|
+
"access": "e2e-access",
|
|
123
|
+
"refresh": "e2e-refresh",
|
|
124
|
+
"expires": time.time() + 30 * 86_400,
|
|
125
|
+
"account_id": "acct-e2e",
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
)
|
|
129
|
+
)
|
|
130
|
+
(hx.hx_home / "auth.json").chmod(0o600)
|
|
131
|
+
# Fresh, so nothing reaches for the real Codex catalogue at startup.
|
|
132
|
+
(hx.hx_home / "models.json").write_text(json.dumps({"fetched_at": time.time(), "models": []}))
|
|
133
|
+
(hx.hx_home / "settings.json").write_text(json.dumps({"models": {"reasoning_effort": "high"}}))
|
|
134
|
+
env = {"HX_MODEL": codex}
|
|
135
|
+
|
|
136
|
+
term = hx.tui(env=env)
|
|
137
|
+
term.wait_for(lambda s: s.splitlines()[-1].rstrip().endswith("gpt-5.6-terra (sub) · high"))
|
|
138
|
+
term.snapshot("a new session starts at the saved effort")
|
|
139
|
+
|
|
140
|
+
term.submit("/effort")
|
|
141
|
+
screen = term.wait_for("Reasoning effort for gpt-5.6-terra")
|
|
142
|
+
term.snapshot("/effort opens on the level in force")
|
|
143
|
+
assert re.search(r"→ ✓ high\s*$", screen, re.MULTILINE)
|
|
144
|
+
term.press("down")
|
|
145
|
+
term.press("enter")
|
|
146
|
+
term.wait_for("Reasoning effort set to xhigh.")
|
|
147
|
+
assert term.lines()[-1].rstrip().endswith("gpt-5.6-terra (sub) · xhigh")
|
|
148
|
+
saved = json.loads((hx.hx_home / "settings.json").read_text())
|
|
149
|
+
assert saved["models"]["reasoning_effort"] == "xhigh"
|
|
150
|
+
|
|
151
|
+
term.submit("/clear")
|
|
152
|
+
term.wait_for(lambda s: "Reasoning effort set" not in s)
|
|
153
|
+
assert term.lines()[-1].rstrip().endswith("gpt-5.6-terra (sub) · xhigh")
|
|
154
|
+
term.press("ctrl+d")
|
|
155
|
+
term.wait_exit()
|
|
156
|
+
|
|
157
|
+
again = hx.tui(env=env)
|
|
158
|
+
again.wait_for(lambda s: s.splitlines()[-1].rstrip().endswith("gpt-5.6-terra (sub) · xhigh"))
|
|
159
|
+
again.snapshot("the next session starts at the new effort")
|
|
160
|
+
|
|
161
|
+
|
|
89
162
|
def test_ctrl_p_palette_runs_a_command(hx: HX) -> None:
|
|
90
163
|
"""ctrl+p opens the palette; filtering and enter runs the command."""
|
|
91
164
|
term = hx.tui(rows=40)
|
|
@@ -7,7 +7,7 @@ import os
|
|
|
7
7
|
import sys
|
|
8
8
|
from pathlib import Path
|
|
9
9
|
|
|
10
|
-
from tests.e2e.conftest import HX
|
|
10
|
+
from tests.e2e.conftest import HX, git
|
|
11
11
|
from tests.e2e.stub import GPT5, SONNET, Stub, call, say
|
|
12
12
|
|
|
13
13
|
ECHO_SERVER = Path(__file__).parent / "fixtures" / "echo_server.py"
|
|
@@ -105,6 +105,55 @@ def test_blank_unreadable_and_shared_agents_md_are_harmless(hx: HX, stub: Stub)
|
|
|
105
105
|
assert "# Project instructions" not in system
|
|
106
106
|
|
|
107
107
|
|
|
108
|
+
def test_agents_md_governs_a_session_started_deep_in_the_repo_and_its_subagents(
|
|
109
|
+
hx: HX, stub: Stub
|
|
110
|
+
) -> None:
|
|
111
|
+
"""Started in a package two levels below the repository root, HX still loads the root's
|
|
112
|
+
AGENTS.md, then the package's own after it, and nothing from outside the repository; a
|
|
113
|
+
subagent the model delegates the work to is held to the same rules."""
|
|
114
|
+
git(hx.project, "init", "-q")
|
|
115
|
+
write(hx.project.parent / "AGENTS.md", "OUTSIDE-RULE: not this repository's.\n")
|
|
116
|
+
write(hx.project / "AGENTS.md", "ROOT-RULE: never create unit tests; verify end to end.\n")
|
|
117
|
+
write(hx.project / "services" / "AGENTS.md", "")
|
|
118
|
+
write(hx.project / "services" / "billing" / "AGENTS.md", "PKG-RULE: money is integer cents.\n")
|
|
119
|
+
pricing = write(
|
|
120
|
+
hx.project / "services" / "billing" / "pricing.py",
|
|
121
|
+
"def subtotal(items):\n return sum(i['cents'] * i['qty'] for i in items)\n",
|
|
122
|
+
)
|
|
123
|
+
stub.script(
|
|
124
|
+
call(
|
|
125
|
+
"Task",
|
|
126
|
+
subagent_type="general",
|
|
127
|
+
prompt="Add apply_discount(total, percent) to pricing.py.",
|
|
128
|
+
description="add discount",
|
|
129
|
+
),
|
|
130
|
+
call("Read", file_path=str(pricing)),
|
|
131
|
+
say("REPORT: added apply_discount; no test files, per AGENTS.md."),
|
|
132
|
+
say("Done, no test files added."),
|
|
133
|
+
)
|
|
134
|
+
result = hx.run(
|
|
135
|
+
"-p", "add a discount helper", "--mode", "bypass", cwd=pricing.parent, check=True
|
|
136
|
+
)
|
|
137
|
+
assert result.stdout.strip() == "Done, no test files added."
|
|
138
|
+
|
|
139
|
+
parent, child, child_after_read, _parent_after = stub.requests
|
|
140
|
+
for request in (parent, child, child_after_read):
|
|
141
|
+
system = request.system
|
|
142
|
+
assert "OUTSIDE-RULE" not in system
|
|
143
|
+
root = system.index("# Project instructions (AGENTS.md)")
|
|
144
|
+
package = system.index("# Project instructions (services/billing/AGENTS.md)")
|
|
145
|
+
assert root < system.index("ROOT-RULE: never create unit tests") < package
|
|
146
|
+
assert package < system.index("PKG-RULE: money is integer cents.")
|
|
147
|
+
assert "Project instructions (services/AGENTS.md)" not in system, "blank is skipped"
|
|
148
|
+
assert system.count("Follow them over your own defaults") == 1
|
|
149
|
+
assert child.system.startswith("Carry out the task"), "the subagent keeps its own prompt"
|
|
150
|
+
|
|
151
|
+
listed = hx.run("prompt", cwd=pricing.parent, check=True).stderr
|
|
152
|
+
assert f"[instructions] {hx.project / 'AGENTS.md'}\n" in listed
|
|
153
|
+
assert f"[instructions] {pricing.parent / 'AGENTS.md'}\n" in listed
|
|
154
|
+
assert str(hx.project.parent / "AGENTS.md") not in listed
|
|
155
|
+
|
|
156
|
+
|
|
108
157
|
def test_skill_is_indexed_then_loaded(hx: HX, stub: Stub) -> None:
|
|
109
158
|
"""Only a skill's name and description are sent up front; calling Skill loads its body."""
|
|
110
159
|
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
|
|
File without changes
|
|
File without changes
|