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.
Files changed (148) hide show
  1. {hx_cli-0.2.8 → hx_cli-0.2.9}/CHANGELOG.md +19 -1
  2. {hx_cli-0.2.8 → hx_cli-0.2.9}/PKG-INFO +8 -1
  3. {hx_cli-0.2.8 → hx_cli-0.2.9}/README.md +7 -0
  4. {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/__init__.py +1 -1
  5. {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/cli.py +16 -5
  6. {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/core/context.py +45 -11
  7. {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/core/loop.py +4 -1
  8. {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/paths.py +36 -1
  9. {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/tui/commands.py +9 -5
  10. {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/tui/format.py +0 -15
  11. {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/tui/runtime.py +1 -1
  12. {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/tui/views/pickers.py +2 -1
  13. {hx_cli-0.2.8 → hx_cli-0.2.9}/tests/e2e/test_cli.py +10 -1
  14. {hx_cli-0.2.8 → hx_cli-0.2.9}/tests/e2e/test_commands.py +28 -0
  15. {hx_cli-0.2.8 → hx_cli-0.2.9}/tests/e2e/test_extend.py +79 -0
  16. {hx_cli-0.2.8 → hx_cli-0.2.9}/.github/workflows/ci.yml +0 -0
  17. {hx_cli-0.2.8 → hx_cli-0.2.9}/.gitignore +0 -0
  18. {hx_cli-0.2.8 → hx_cli-0.2.9}/examples/hooks/check.sh +0 -0
  19. {hx_cli-0.2.8 → hx_cli-0.2.9}/install.sh +0 -0
  20. {hx_cli-0.2.8 → hx_cli-0.2.9}/pyproject.toml +0 -0
  21. {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/agents/__init__.py +0 -0
  22. {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/agents/definitions.py +0 -0
  23. {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/agents/subagent.py +0 -0
  24. {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/auth/__init__.py +0 -0
  25. {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/auth/oauth/__init__.py +0 -0
  26. {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/auth/oauth/browser.py +0 -0
  27. {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/auth/oauth/callback.py +0 -0
  28. {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/auth/oauth/codex.py +0 -0
  29. {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/auth/oauth/devin.py +0 -0
  30. {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/auth/oauth/pkce.py +0 -0
  31. {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/auth/resolve.py +0 -0
  32. {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/auth/store.py +0 -0
  33. {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/config.py +0 -0
  34. {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/core/__init__.py +0 -0
  35. {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/core/checkpoints.py +0 -0
  36. {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/core/compaction.py +0 -0
  37. {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/core/events.py +0 -0
  38. {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/core/images.py +0 -0
  39. {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/core/lateinject.py +0 -0
  40. {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/core/messages.py +0 -0
  41. {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/core/session.py +0 -0
  42. {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/core/title.py +0 -0
  43. {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/core/usage.py +0 -0
  44. {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/docs.py +0 -0
  45. {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/frontmatter.py +0 -0
  46. {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/git.py +0 -0
  47. {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/hooks/__init__.py +0 -0
  48. {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/hooks/engine.py +0 -0
  49. {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/hooks/spec.py +0 -0
  50. {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/keys.py +0 -0
  51. {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/mcp/__init__.py +0 -0
  52. {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/mcp/client.py +0 -0
  53. {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/mcp/manager.py +0 -0
  54. {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/mcp/oauth.py +0 -0
  55. {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/net.py +0 -0
  56. {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/permissions/__init__.py +0 -0
  57. {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/permissions/engine.py +0 -0
  58. {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/permissions/parser.py +0 -0
  59. {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/permissions/sandbox.py +0 -0
  60. {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/providers/__init__.py +0 -0
  61. {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/providers/base.py +0 -0
  62. {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/providers/codex.py +0 -0
  63. {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/providers/codex_catalogue.py +0 -0
  64. {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/providers/devin.py +0 -0
  65. {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/providers/devin_catalogue.py +0 -0
  66. {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/providers/devin_wire.py +0 -0
  67. {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/providers/models.py +0 -0
  68. {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/providers/openrouter.py +0 -0
  69. {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/providers/protowire.py +0 -0
  70. {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/providers/registry.py +0 -0
  71. {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/providers/responses_codec.py +0 -0
  72. {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/skills/__init__.py +0 -0
  73. {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/skills/loader.py +0 -0
  74. {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/skills/runtime.py +0 -0
  75. {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/term/__init__.py +0 -0
  76. {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/term/ansi.py +0 -0
  77. {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/term/buffer.py +0 -0
  78. {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/term/component.py +0 -0
  79. {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/term/demo.py +0 -0
  80. {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/term/editor.py +0 -0
  81. {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/term/keydecode.py +0 -0
  82. {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/term/loop.py +0 -0
  83. {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/term/markdown.py +0 -0
  84. {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/term/modifiers.py +0 -0
  85. {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/term/primitives.py +0 -0
  86. {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/term/sanitize.py +0 -0
  87. {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/term/screen.py +0 -0
  88. {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/term/syntax.py +0 -0
  89. {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/term/terminal.py +0 -0
  90. {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/term/undo.py +0 -0
  91. {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/term/width.py +0 -0
  92. {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/term/word_nav.py +0 -0
  93. {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/tools/__init__.py +0 -0
  94. {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/tools/anchors.py +0 -0
  95. {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/tools/base.py +0 -0
  96. {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/tools/bash.py +0 -0
  97. {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/tools/edit.py +0 -0
  98. {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/tools/glob.py +0 -0
  99. {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/tools/grep.py +0 -0
  100. {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/tools/output.py +0 -0
  101. {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/tools/read.py +0 -0
  102. {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/tools/registry.py +0 -0
  103. {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/tools/symbols.py +0 -0
  104. {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/tools/task.py +0 -0
  105. {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/tools/todo.py +0 -0
  106. {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/tools/websearch.py +0 -0
  107. {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/tools/write.py +0 -0
  108. {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/trace.py +0 -0
  109. {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/tui/__init__.py +0 -0
  110. {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/tui/app.py +0 -0
  111. {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/tui/clipboard.py +0 -0
  112. {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/tui/fuzzy.py +0 -0
  113. {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/tui/glyphs.py +0 -0
  114. {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/tui/killring.py +0 -0
  115. {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/tui/limits.py +0 -0
  116. {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/tui/paint.py +0 -0
  117. {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/tui/renderers.py +0 -0
  118. {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/tui/roles.py +0 -0
  119. {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/tui/theme.py +0 -0
  120. {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/tui/theme_json.py +0 -0
  121. {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/tui/themes/ansi.json +0 -0
  122. {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/tui/themes/dark.json +0 -0
  123. {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/tui/themes/light.json +0 -0
  124. {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/tui/views/__init__.py +0 -0
  125. {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/tui/views/blocks.py +0 -0
  126. {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/tui/views/dialog.py +0 -0
  127. {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/tui/views/login.py +0 -0
  128. {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/tui/views/permission.py +0 -0
  129. {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/tui/views/prompt.py +0 -0
  130. {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/tui/views/status.py +0 -0
  131. {hx_cli-0.2.8 → hx_cli-0.2.9}/src/hx/tui/views/transcript.py +0 -0
  132. {hx_cli-0.2.8 → hx_cli-0.2.9}/tests/__init__.py +0 -0
  133. {hx_cli-0.2.8 → hx_cli-0.2.9}/tests/conftest.py +0 -0
  134. {hx_cli-0.2.8 → hx_cli-0.2.9}/tests/e2e/__init__.py +0 -0
  135. {hx_cli-0.2.8 → hx_cli-0.2.9}/tests/e2e/conftest.py +0 -0
  136. {hx_cli-0.2.8 → hx_cli-0.2.9}/tests/e2e/fixtures/echo_server.py +0 -0
  137. {hx_cli-0.2.8 → hx_cli-0.2.9}/tests/e2e/report.py +0 -0
  138. {hx_cli-0.2.8 → hx_cli-0.2.9}/tests/e2e/stub.py +0 -0
  139. {hx_cli-0.2.8 → hx_cli-0.2.9}/tests/e2e/terminal.py +0 -0
  140. {hx_cli-0.2.8 → hx_cli-0.2.9}/tests/e2e/test_chat.py +0 -0
  141. {hx_cli-0.2.8 → hx_cli-0.2.9}/tests/e2e/test_input.py +0 -0
  142. {hx_cli-0.2.8 → hx_cli-0.2.9}/tests/e2e/test_print.py +0 -0
  143. {hx_cli-0.2.8 → hx_cli-0.2.9}/tests/e2e/test_tools.py +0 -0
  144. {hx_cli-0.2.8 → hx_cli-0.2.9}/tests/test_live.py +0 -0
  145. {hx_cli-0.2.8 → hx_cli-0.2.9}/tests/test_live_codex.py +0 -0
  146. {hx_cli-0.2.8 → hx_cli-0.2.9}/tests/test_live_devin.py +0 -0
  147. {hx_cli-0.2.8 → hx_cli-0.2.9}/tests/test_live_tavily.py +0 -0
  148. {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.8...HEAD
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.8
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
@@ -1,5 +1,5 @@
1
1
  """HX - an agent harness for the terminal."""
2
2
 
3
- __version__ = "0.2.8"
3
+ __version__ = "0.2.9"
4
4
 
5
5
  __all__ = ["__version__"]
@@ -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 ContextBuilder, build_project_context, load_system_prompt
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 contaminates that output.
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 AGENTS.md, cwd, git branch, top-level listing
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
- def build_project_context(cwd: Path) -> str:
242
- """Static per-session project preamble: AGENTS.md contents, cwd, git branch, listing.
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
- agents_md = cwd / "AGENTS.md"
260
- if agents_md.is_file():
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 permissions problem on an
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 directory skeleton if it does not exist."""
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, tilde
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 with AGENTS.md folded in; provenance goes to stderr."""
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