@pugi/cli 0.1.0-beta.1 → 0.1.0-beta.11

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 (41) hide show
  1. package/THIRD_PARTY_NOTICES.md +40 -0
  2. package/assets/pugi-mascot.ansi +15 -40
  3. package/dist/core/edits/worktree.js +322 -0
  4. package/dist/core/engine/anvil-client.js +16 -0
  5. package/dist/core/engine/budgets.js +89 -0
  6. package/dist/core/engine/native-pugi.js +112 -12
  7. package/dist/core/engine/prompts.js +8 -0
  8. package/dist/core/engine/tool-bridge.js +267 -8
  9. package/dist/core/init/scaffold.js +195 -0
  10. package/dist/core/lsp/client.js +719 -0
  11. package/dist/core/repl/codebase-survey.js +308 -0
  12. package/dist/core/repl/init-interview.js +457 -0
  13. package/dist/core/repl/onboarding-state.js +297 -0
  14. package/dist/core/repl/session.js +72 -1
  15. package/dist/core/repl/slash-commands.js +41 -0
  16. package/dist/core/settings.js +28 -0
  17. package/dist/core/skills/defaults.js +457 -0
  18. package/dist/runtime/cli.js +366 -14
  19. package/dist/runtime/commands/delegate.js +289 -0
  20. package/dist/runtime/commands/lsp.js +206 -0
  21. package/dist/runtime/commands/patch.js +128 -0
  22. package/dist/runtime/commands/roster.js +117 -0
  23. package/dist/runtime/commands/worktree.js +177 -0
  24. package/dist/runtime/plan-decompose.js +531 -0
  25. package/dist/tools/apply-patch.js +495 -0
  26. package/dist/tools/ask-user.js +115 -0
  27. package/dist/tools/lsp-tools.js +189 -0
  28. package/dist/tools/registry.js +26 -0
  29. package/dist/tools/skill-tool.js +96 -0
  30. package/dist/tools/tasks.js +208 -0
  31. package/dist/tui/ask-modal.js +2 -2
  32. package/dist/tui/conversation-pane.js +1 -1
  33. package/dist/tui/input-box.js +1 -1
  34. package/dist/tui/markdown-render.js +4 -4
  35. package/dist/tui/repl-render.js +169 -10
  36. package/dist/tui/repl-splash.js +2 -2
  37. package/dist/tui/repl.js +18 -5
  38. package/dist/tui/splash.js +1 -1
  39. package/dist/tui/update-banner.js +1 -1
  40. package/docs/examples/codegraph.mcp.json +10 -0
  41. package/package.json +6 -4
@@ -0,0 +1,40 @@
1
+ # Third-Party Notices
2
+
3
+ `@pugi/cli` bundles default skill content from third parties on `pugi init`.
4
+ Each entry below carries the full upstream MIT license text required for
5
+ redistribution.
6
+
7
+ ## mattpocock/skills
8
+
9
+ Pugi bundles the following skill bodies (ported, attribution preserved in
10
+ each `SKILL.md` header) from https://github.com/mattpocock/skills:
11
+
12
+ - `zoom-out` — `skills/engineering/zoom-out/SKILL.md`
13
+ - `diagnose` — `skills/engineering/diagnose/SKILL.md`
14
+ - `grill-me` — `skills/productivity/grill-me/SKILL.md`
15
+
16
+ Upstream license follows verbatim.
17
+
18
+ ---
19
+
20
+ MIT License
21
+
22
+ Copyright (c) 2026 Matt Pocock
23
+
24
+ Permission is hereby granted, free of charge, to any person obtaining a copy
25
+ of this software and associated documentation files (the "Software"), to deal
26
+ in the Software without restriction, including without limitation the rights
27
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
28
+ copies of the Software, and to permit persons to whom the Software is
29
+ furnished to do so, subject to the following conditions:
30
+
31
+ The above copyright notice and this permission notice shall be included in all
32
+ copies or substantial portions of the Software.
33
+
34
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
35
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
36
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
37
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
38
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
39
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
40
+ SOFTWARE.
@@ -1,41 +1,16 @@
1
- [?25l▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
2
- ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
3
- ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
4
- ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
5
- ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
6
- ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
7
- ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
8
- ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
9
- ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
10
- ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
11
- ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
12
- ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
13
- ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
14
- ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
15
- ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
16
- ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
17
- ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
18
- ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
19
- ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
20
- ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
21
- ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
22
- ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
23
- ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
24
- ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
25
- ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
26
- ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
27
- ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
28
- ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
29
- ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
30
- ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
31
- ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
32
- ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
33
- ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
34
- ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
35
- ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
36
- ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
37
- ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
38
- ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
39
- ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
40
- ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
1
+ [?25l 
2
+ 
3
+ 
4
+ ▄▄▄▄▄▄▄▄▄▄▄▄▄▄ 
5
+ ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ 
6
+ ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ 
7
+ ▄▄▄▄▄▄▄▄▄▄▄▄ 
8
+ ▄▄▄▄▄▄▄▄▄▄▄▄ 
9
+ ▄▄▄▄▄▄▄▄▄▄▄ 
10
+ ▄▄▄▄▄▄ 
11
+ ▄▄▄▄ ▄▄ 
12
+ ▄ ▄ ▄▄▄ 
13
+ ▄ 
14
+ 
15
+ 
41
16
  [?25h
@@ -0,0 +1,322 @@
1
+ /**
2
+ * Worktree isolation — α7.7 Phase 1.
3
+ *
4
+ * Wraps `git worktree add` so a long agent loop (build / consensus
5
+ * review / multi-file refactor) can land its edits into a scratch
6
+ * workspace, run the validators against THAT path, and only then promote
7
+ * the resulting diff back to the operator's main working tree. The
8
+ * primary win is safety: a half-applied refactor never corrupts the
9
+ * operator's branch.
10
+ *
11
+ * Three operations:
12
+ *
13
+ * - `createWorktree(branch)` — spawns `git worktree add --detach`
14
+ * under `.pugi/worktrees/<uuid>` based on the supplied branch (or
15
+ * HEAD when omitted). Returns the absolute path + a `cleanup()`
16
+ * callback. The dir lives under `.pugi/` so the existing `.gitignore`
17
+ * for that subtree applies (no accidental commits of the scratch
18
+ * state to the main repo).
19
+ *
20
+ * - `promoteWorktree(worktreePath, cwd)` — diffs the worktree against
21
+ * its base commit and applies the diff to the main `cwd` via
22
+ * `git apply`. Refuses if the main cwd has staged changes that
23
+ * would conflict; the operator must commit or stash first.
24
+ *
25
+ * - `dropWorktree(worktreePath)` — removes the worktree both from
26
+ * git's bookkeeping (`git worktree remove --force`) and from disk.
27
+ * Idempotent; a partially-removed worktree (`git` already cleaned
28
+ * up but dir survived) is handled.
29
+ *
30
+ * Brand voice: ASCII only, no emoji, no banned words.
31
+ */
32
+ import { spawnSync } from 'node:child_process';
33
+ import { existsSync, mkdirSync, realpathSync, rmSync } from 'node:fs';
34
+ import { randomUUID } from 'node:crypto';
35
+ import { resolve, sep } from 'node:path';
36
+ import { OperatorAbortedError } from '../../tools/file-tools.js';
37
+ import { applySecurityGate } from './security-gate.js';
38
+ import { extractPatchPaths } from '../../tools/apply-patch.js';
39
+ /**
40
+ * Create a scratch worktree under `.pugi/worktrees/<uuid>`. The path is
41
+ * guaranteed unique (uuid) so multiple agent loops can run in parallel
42
+ * without collision.
43
+ */
44
+ export function createWorktree(opts) {
45
+ if (opts.cancellation && opts.cancellation.isAborted) {
46
+ return { ok: false, reason: 'operator_aborted', detail: 'createWorktree aborted' };
47
+ }
48
+ // Confirm we're inside a git repo. `git rev-parse --git-dir` is the
49
+ // canonical check and avoids a misleading error message later when
50
+ // `git worktree add` runs in a non-repo.
51
+ const gitDir = runGit(['rev-parse', '--git-dir'], opts.cwd);
52
+ if (gitDir.status !== 0) {
53
+ return {
54
+ ok: false,
55
+ reason: 'not_a_git_repo',
56
+ detail: `not a git repo: ${opts.cwd}`,
57
+ };
58
+ }
59
+ // Resolve base SHA. When the operator named a branch we honor it; the
60
+ // default is HEAD. We capture the SHA up-front so `promoteWorktree`
61
+ // can `git diff <baseSha>..HEAD` deterministically even if the main
62
+ // working tree has moved forward since.
63
+ const baseRef = opts.branch ?? 'HEAD';
64
+ const baseShaResult = runGit(['rev-parse', baseRef], opts.cwd);
65
+ if (baseShaResult.status !== 0) {
66
+ return {
67
+ ok: false,
68
+ reason: 'git_command_failed',
69
+ detail: `cannot resolve base ref ${baseRef}: ${baseShaResult.stderr}`,
70
+ };
71
+ }
72
+ const baseSha = baseShaResult.stdout.trim();
73
+ const worktreeRoot = resolve(opts.cwd, '.pugi', 'worktrees');
74
+ mkdirSync(worktreeRoot, { recursive: true });
75
+ const worktreePath = resolve(worktreeRoot, randomUUID());
76
+ // `--detach` keeps the worktree on a detached HEAD so we don't
77
+ // collide with branch checkouts on the main tree. The worktree is
78
+ // throwaway — there is no branch name to track.
79
+ const create = runGit(['worktree', 'add', '--detach', worktreePath, baseSha], opts.cwd);
80
+ if (create.status !== 0) {
81
+ return {
82
+ ok: false,
83
+ reason: 'git_command_failed',
84
+ detail: `git worktree add failed: ${create.stderr}`,
85
+ };
86
+ }
87
+ const handle = {
88
+ path: worktreePath,
89
+ baseSha,
90
+ cleanup: () => {
91
+ const r = dropWorktree(worktreePath, opts.cwd);
92
+ if (!r.ok && r.reason !== 'worktree_missing') {
93
+ // Swallow non-fatal cleanup failures so the agent loop doesn't
94
+ // hard-crash on the happy path. The diagnostic still surfaces
95
+ // via the JSON output on the `pugi worktree drop` command.
96
+ }
97
+ },
98
+ };
99
+ return { ok: true, value: handle };
100
+ }
101
+ /**
102
+ * Diff the worktree against its base and apply the diff to the main cwd.
103
+ *
104
+ * Implementation notes:
105
+ *
106
+ * - We run `git diff --binary <baseSha>` inside the worktree (NOT
107
+ * `git diff <worktree>..HEAD` from the main tree — the worktree's
108
+ * HEAD is detached at `baseSha`, so the meaningful diff is the
109
+ * UNCOMMITTED changes the agent wrote into it).
110
+ * - `--binary` ensures non-text files (assets, images) survive the
111
+ * round-trip; without it `git apply` fails on any binary delta.
112
+ * - We always run `git apply --check` first so a refusal does not
113
+ * leave the main tree half-modified.
114
+ */
115
+ export function promoteWorktree(opts) {
116
+ if (opts.cancellation && opts.cancellation.isAborted) {
117
+ return { ok: false, reason: 'operator_aborted', detail: 'promoteWorktree aborted' };
118
+ }
119
+ if (!existsSync(opts.worktreePath)) {
120
+ return {
121
+ ok: false,
122
+ reason: 'worktree_missing',
123
+ detail: `worktree path does not exist: ${opts.worktreePath}`,
124
+ };
125
+ }
126
+ // Capture the diff against the base SHA. `git diff <baseSha>`
127
+ // (no `--cached`) compares the WORKING TREE against the base, which
128
+ // covers both unstaged AND staged changes in a single invocation —
129
+ // anything the working tree shows is included. `--binary` ensures
130
+ // non-text files survive the round-trip.
131
+ //
132
+ // Note: untracked files that were NEVER staged stay invisible — git
133
+ // diff has no native flag to include them. The agent loop must
134
+ // `git add` any new file it wants promoted; the CLI surface
135
+ // documents this explicitly so the contract is not surprising.
136
+ // (Staging is enough to expose the file; the file does not need to
137
+ // be committed.)
138
+ const diffResult = runGit(['diff', '--binary', opts.baseSha], opts.worktreePath);
139
+ if (diffResult.status !== 0) {
140
+ return {
141
+ ok: false,
142
+ reason: 'git_command_failed',
143
+ detail: `git diff failed: ${diffResult.stderr}`,
144
+ };
145
+ }
146
+ const diffText = diffResult.stdout;
147
+ if (diffText.trim().length === 0) {
148
+ return { ok: true, value: { filesChanged: 0 } };
149
+ }
150
+ // SECURITY GATE (R1 fix 2026-05-26, PR #413 r1) — every path mentioned
151
+ // in the worktree's diff goes through the same `applySecurityGate`
152
+ // chokepoint as the apply_patch + Layer A/B/C applicators. A staged
153
+ // `.env` (or `../../etc/passwd`, or a symlink into a protected target)
154
+ // inside the worktree must NOT slip into the operator's main tree just
155
+ // because the worktree itself was a sandboxed scratch dir. Without
156
+ // this gate, `promoteWorktree` was a clean bypass of every other edit
157
+ // primitive's safety net.
158
+ const diffPaths = extractPatchPaths(diffText);
159
+ const failedPaths = [];
160
+ for (const file of diffPaths) {
161
+ const gate = applySecurityGate(file, { cwd: opts.cwd, toolName: 'layer-c' });
162
+ if (!gate.ok) {
163
+ failedPaths.push(`${file}: ${gate.reason}`);
164
+ }
165
+ }
166
+ if (failedPaths.length > 0) {
167
+ return {
168
+ ok: false,
169
+ reason: 'protected_file_in_worktree',
170
+ detail: `worktree diff touches protected/escaping paths: ${failedPaths.join('; ')}`,
171
+ files: failedPaths,
172
+ };
173
+ }
174
+ // `git apply --check` validates the diff against the main tree first.
175
+ // Refuse early on conflict so the operator can resolve before we
176
+ // touch any file.
177
+ const check = runGit(['apply', '--check', '-'], opts.cwd, diffText);
178
+ if (check.status !== 0) {
179
+ return {
180
+ ok: false,
181
+ reason: 'apply_conflict',
182
+ detail: `git apply --check rejected: ${check.stderr}`,
183
+ };
184
+ }
185
+ if (opts.dryRun) {
186
+ return { ok: true, value: { filesChanged: countDiffFiles(diffText) } };
187
+ }
188
+ const apply = runGit(['apply', '-'], opts.cwd, diffText);
189
+ if (apply.status !== 0) {
190
+ return {
191
+ ok: false,
192
+ reason: 'apply_failed',
193
+ detail: `git apply failed: ${apply.stderr}`,
194
+ };
195
+ }
196
+ return { ok: true, value: { filesChanged: countDiffFiles(diffText) } };
197
+ }
198
+ /**
199
+ * Drop a worktree both from git's bookkeeping and from disk. Idempotent —
200
+ * a missing path returns `worktree_missing` which the caller can ignore
201
+ * on the cleanup-after-error path.
202
+ *
203
+ * Security (R1 fix 2026-05-26, PR #413 r1): we MUST validate the path is
204
+ * a real subdirectory of `<cwd>/.pugi/worktrees/` BEFORE running either
205
+ * `git worktree remove --force` or `rmSync`. Without this gate, a
206
+ * typo like `pugi worktree drop ../some-dir` recursively deleted an
207
+ * arbitrary directory: `git worktree remove` correctly failed (path not
208
+ * registered), but the `rmSync(worktreePath, recursive: true)` below
209
+ * still fired regardless.
210
+ *
211
+ * We resolve both `cwd` and `worktreePath` through `realpathSync` so a
212
+ * caller passing a symlink that points outside `.pugi/worktrees/` is
213
+ * still rejected. When the worktree path does not exist on disk at all
214
+ * (idempotent re-drop of an already-removed worktree), we fall back to
215
+ * the lexical containment check — the rejection only matters when there
216
+ * is a real directory to delete.
217
+ */
218
+ export function dropWorktree(worktreePath, cwd) {
219
+ // SECURITY GATE — validate containment under `<cwd>/.pugi/worktrees/`
220
+ // BEFORE any destructive call. Two-tier check:
221
+ // 1. lexical containment using resolved (but not realpath'd) paths,
222
+ // catches the operator-typo + missing-worktree cases.
223
+ // 2. realpath containment when the path exists, catches symlink
224
+ // shenanigans.
225
+ const scratchRootLexical = resolve(cwd, '.pugi', 'worktrees');
226
+ const worktreeLexical = resolve(cwd, worktreePath);
227
+ const insideLexical = worktreeLexical.startsWith(scratchRootLexical + sep) &&
228
+ worktreeLexical !== scratchRootLexical;
229
+ if (!insideLexical) {
230
+ return {
231
+ ok: false,
232
+ reason: 'invalid_worktree_path',
233
+ detail: `worktree path ${worktreePath} is not under ${scratchRootLexical}`,
234
+ };
235
+ }
236
+ if (existsSync(worktreeLexical)) {
237
+ try {
238
+ const realScratchRoot = realpathSync(scratchRootLexical);
239
+ const realWorktree = realpathSync(worktreeLexical);
240
+ const insideReal = realWorktree.startsWith(realScratchRoot + sep) &&
241
+ realWorktree !== realScratchRoot;
242
+ if (!insideReal) {
243
+ return {
244
+ ok: false,
245
+ reason: 'invalid_worktree_path',
246
+ detail: `worktree realpath ${realWorktree} escapes ${realScratchRoot}`,
247
+ };
248
+ }
249
+ }
250
+ catch (error) {
251
+ // realpath failed for a path that exists — surface as
252
+ // invalid_worktree_path so we never recurse into rmSync on an
253
+ // unreadable path.
254
+ return {
255
+ ok: false,
256
+ reason: 'invalid_worktree_path',
257
+ detail: `cannot realpath worktree path: ${error instanceof Error ? error.message : String(error)}`,
258
+ };
259
+ }
260
+ }
261
+ // `git worktree remove --force` cleans the metadata in `.git/worktrees`.
262
+ // If the worktree was created by another process and already pruned,
263
+ // git returns non-zero — we still try to `rmSync` the dir to leave the
264
+ // filesystem consistent. Path containment has already been validated
265
+ // above so the rmSync below is bounded to `.pugi/worktrees/`.
266
+ const remove = runGit(['worktree', 'remove', '--force', worktreeLexical], cwd);
267
+ const gitCleanFailed = remove.status !== 0;
268
+ if (existsSync(worktreeLexical)) {
269
+ try {
270
+ rmSync(worktreeLexical, { recursive: true, force: true });
271
+ }
272
+ catch (error) {
273
+ if (gitCleanFailed) {
274
+ return {
275
+ ok: false,
276
+ reason: 'git_command_failed',
277
+ detail: `git worktree remove failed AND rmSync failed: ${error instanceof Error ? error.message : String(error)}`,
278
+ };
279
+ }
280
+ }
281
+ }
282
+ if (gitCleanFailed && !worktreeLexical.includes(`${sep}.pugi${sep}worktrees${sep}`)) {
283
+ // A worktree that wasn't created by us (path is outside our naming
284
+ // convention) is suspicious — surface the failure so the operator
285
+ // can diagnose.
286
+ return {
287
+ ok: false,
288
+ reason: 'git_command_failed',
289
+ detail: `git worktree remove failed: ${remove.stderr}`,
290
+ };
291
+ }
292
+ return { ok: true, value: undefined };
293
+ }
294
+ function countDiffFiles(diff) {
295
+ // Count `diff --git a/... b/...` headers. Cheap and unambiguous.
296
+ let count = 0;
297
+ for (const line of diff.split('\n')) {
298
+ if (line.startsWith('diff --git '))
299
+ count += 1;
300
+ }
301
+ return count;
302
+ }
303
+ function runGit(args, cwd, stdin) {
304
+ return spawnSync('git', args, {
305
+ cwd,
306
+ input: stdin,
307
+ encoding: 'utf8',
308
+ maxBuffer: 64 * 1024 * 1024,
309
+ });
310
+ }
311
+ /**
312
+ * Test-only helper exporting the internal git runner so specs can stub
313
+ * the spawn surface when running on a CI host without a global git.
314
+ */
315
+ export const __test__ = { runGit, countDiffFiles };
316
+ /**
317
+ * Re-export the abort marker so the worktree CLI surface can fold the
318
+ * exception into a clean exit code without needing to import from the
319
+ * tools layer.
320
+ */
321
+ export { OperatorAbortedError };
322
+ //# sourceMappingURL=worktree.js.map
@@ -67,6 +67,22 @@ export class AnvilEngineLoopClient {
67
67
  tools,
68
68
  maxTokens: options.maxTokens,
69
69
  temperature: options.temperature,
70
+ // β1 (audit E2): the admin-api `EngineRequestDto` accepts
71
+ // these optional fields (see `pugi-engine.controller.ts:230`
72
+ // EngineRequestDto schema). Before this fix the CLI dropped
73
+ // them, which forced the controller to fall back to legacy
74
+ // per-persona resolution + emit `command="(none)"` in its
75
+ // structured logs. `undefined` keys are stripped by
76
+ // `JSON.stringify` so the payload stays clean for fixture
77
+ // clients that exact-match the body shape.
78
+ command: options.command,
79
+ // β1a r1: `tag` is `EngineDispatchTag` object shape now —
80
+ // `JSON.stringify` serialises it as `{tag, priority?,
81
+ // budget_hint?}` matching `EngineDispatchTagDto`. Previously
82
+ // this was a bare string and the server's `IsIn` validator
83
+ // rejected every payload with HTTP 400.
84
+ tag: options.tag,
85
+ model: options.model,
70
86
  }),
71
87
  signal: controller.signal,
72
88
  });
@@ -0,0 +1,89 @@
1
+ /**
2
+ * β1 defaults. Source of truth for the per-command budget envelope.
3
+ * The runtime is allowed to look these up directly (no need to round
4
+ * trip through settings.json when no override is in play).
5
+ */
6
+ export const beta1DefaultBudgets = {
7
+ fix: { maxTokens: 30_000, maxToolCalls: 20 },
8
+ code: { maxTokens: 30_000, maxToolCalls: 20 },
9
+ build: { maxTokens: 200_000, maxToolCalls: 30 },
10
+ plan: { maxTokens: 200_000, maxToolCalls: 8 },
11
+ explain: { maxTokens: 20_000, maxToolCalls: 5 },
12
+ review_triple: { maxTokens: 100_000, maxToolCalls: 10 },
13
+ };
14
+ /**
15
+ * Hard upper bounds. Anything above this is treated as user error
16
+ * (likely a typo or misplaced decimal) and rejected by
17
+ * `assertBudgetWithinTier`. Stops a careless settings.json edit from
18
+ * silently authorising a 100M-token run.
19
+ */
20
+ export const HARD_MAX_TOKENS = 5_000_000;
21
+ export const HARD_MAX_TOOL_CALLS = 500;
22
+ /**
23
+ * Compute the effective budget for a given command, applying:
24
+ * 1. β1 defaults
25
+ * 2. settings.json `budgets.<command>` partial overrides
26
+ * 3. task-level override (caller-provided, e.g. CLI `--max-tokens`)
27
+ *
28
+ * Throws `BudgetConfigError` when the resolved budget exceeds the
29
+ * HARD_MAX_* caps so misconfigured settings.json fails fast.
30
+ */
31
+ export function resolveBudget(command, settings, override) {
32
+ const base = beta1DefaultBudgets[command];
33
+ const settingsBudget = readSettingsBudget(settings, command);
34
+ const resolved = {
35
+ maxTokens: override?.maxTokens ??
36
+ settingsBudget?.maxTokens ??
37
+ base.maxTokens,
38
+ maxToolCalls: override?.maxToolCalls ??
39
+ settingsBudget?.maxToolCalls ??
40
+ base.maxToolCalls,
41
+ };
42
+ assertBudgetWithinTier(command, resolved);
43
+ return resolved;
44
+ }
45
+ export class BudgetConfigError extends Error {
46
+ constructor(message) {
47
+ super(message);
48
+ this.name = 'BudgetConfigError';
49
+ }
50
+ }
51
+ export function assertBudgetWithinTier(command, budget) {
52
+ if (!Number.isFinite(budget.maxTokens) || budget.maxTokens <= 0) {
53
+ throw new BudgetConfigError(`budget[${command}].maxTokens must be a positive number, got ${budget.maxTokens}`);
54
+ }
55
+ if (!Number.isFinite(budget.maxToolCalls) || budget.maxToolCalls <= 0) {
56
+ throw new BudgetConfigError(`budget[${command}].maxToolCalls must be a positive number, got ${budget.maxToolCalls}`);
57
+ }
58
+ if (budget.maxTokens > HARD_MAX_TOKENS) {
59
+ throw new BudgetConfigError(`budget[${command}].maxTokens=${budget.maxTokens} exceeds hard cap ${HARD_MAX_TOKENS}`);
60
+ }
61
+ if (budget.maxToolCalls > HARD_MAX_TOOL_CALLS) {
62
+ throw new BudgetConfigError(`budget[${command}].maxToolCalls=${budget.maxToolCalls} exceeds hard cap ${HARD_MAX_TOOL_CALLS}`);
63
+ }
64
+ }
65
+ /**
66
+ * Pull a settings.json budget override for the given command, with
67
+ * defensive typing. `PugiSettings` does not yet declare `budgets`
68
+ * formally (β1 is the first sprint to land it) so we cast via unknown
69
+ * and validate each field at the boundary.
70
+ */
71
+ function readSettingsBudget(settings, command) {
72
+ if (!settings)
73
+ return undefined;
74
+ const root = settings.budgets;
75
+ if (!root || typeof root !== 'object' || Array.isArray(root))
76
+ return undefined;
77
+ const map = root;
78
+ const entry = map[command];
79
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry))
80
+ return undefined;
81
+ const e = entry;
82
+ const out = {};
83
+ if (typeof e['maxTokens'] === 'number')
84
+ out.maxTokens = e['maxTokens'];
85
+ if (typeof e['maxToolCalls'] === 'number')
86
+ out.maxToolCalls = e['maxToolCalls'];
87
+ return out;
88
+ }
89
+ //# sourceMappingURL=budgets.js.map