pi-usereq 0.7.0 → 0.10.0

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 (772) hide show
  1. package/.gitignore +2 -0
  2. package/CHANGELOG.md +6 -0
  3. package/README.md +1 -1
  4. package/docs/pi.dev/coding-agent-docs/custom-provider.md +42 -1
  5. package/docs/pi.dev/coding-agent-docs/extensions.md +302 -28
  6. package/docs/pi.dev/coding-agent-docs/keybindings.md +23 -1
  7. package/docs/pi.dev/coding-agent-docs/models.md +3 -0
  8. package/docs/pi.dev/coding-agent-docs/packages.md +2 -2
  9. package/docs/pi.dev/coding-agent-docs/prompt-templates.md +21 -0
  10. package/docs/pi.dev/coding-agent-docs/providers.md +1 -0
  11. package/docs/pi.dev/coding-agent-docs/rpc.md +36 -6
  12. package/docs/pi.dev/coding-agent-docs/sdk.md +30 -5
  13. package/docs/pi.dev/coding-agent-docs/session.md +1 -1
  14. package/docs/pi.dev/coding-agent-docs/settings.md +2 -1
  15. package/docs/pi.dev/coding-agent-docs/skills.md +12 -12
  16. package/docs/pi.dev/coding-agent-docs/tree.md +2 -0
  17. package/docs/pi.dev/coding-agent-docs/tui.md +31 -0
  18. package/docs/pi.dev/mom-docs/new.md +4 -4
  19. package/package.json +1 -1
  20. package/pi-usereq/docs/REFERENCES.md +5184 -24470
  21. package/pi-usereq/docs/REQUIREMENTS.md +300 -166
  22. package/pi-usereq/docs/WORKFLOW.md +303 -314
  23. package/pi.dev-src/pi-mono/agent/src/agent-loop.ts +683 -0
  24. package/pi.dev-src/pi-mono/agent/src/agent.ts +543 -0
  25. package/pi.dev-src/pi-mono/agent/src/index.ts +8 -0
  26. package/pi.dev-src/pi-mono/agent/src/proxy.ts +367 -0
  27. package/pi.dev-src/pi-mono/agent/src/types.ts +364 -0
  28. package/pi.dev-src/pi-mono/agent/test/agent-loop.test.ts +1181 -0
  29. package/pi.dev-src/pi-mono/agent/test/agent.test.ts +463 -0
  30. package/pi.dev-src/pi-mono/agent/test/e2e.test.ts +404 -0
  31. package/pi.dev-src/pi-mono/agent/test/utils/calculate.ts +32 -0
  32. package/pi.dev-src/pi-mono/agent/test/utils/get-current-time.ts +46 -0
  33. package/pi.dev-src/pi-mono/agent/vitest.config.ts +9 -0
  34. package/pi.dev-src/pi-mono/ai/bedrock-provider.d.ts +1 -0
  35. package/pi.dev-src/pi-mono/ai/bedrock-provider.js +1 -0
  36. package/pi.dev-src/pi-mono/ai/scripts/generate-models.ts +1635 -0
  37. package/pi.dev-src/pi-mono/ai/scripts/generate-test-image.ts +34 -0
  38. package/pi.dev-src/pi-mono/ai/src/api-registry.ts +98 -0
  39. package/pi.dev-src/pi-mono/ai/src/bedrock-provider.ts +6 -0
  40. package/pi.dev-src/pi-mono/ai/src/cli.ts +133 -0
  41. package/pi.dev-src/pi-mono/ai/src/env-api-keys.ts +134 -0
  42. package/pi.dev-src/pi-mono/ai/src/index.ts +35 -0
  43. package/pi.dev-src/pi-mono/ai/src/models.generated.ts +14928 -0
  44. package/pi.dev-src/pi-mono/ai/src/models.ts +82 -0
  45. package/pi.dev-src/pi-mono/ai/src/oauth.ts +1 -0
  46. package/pi.dev-src/pi-mono/ai/src/providers/amazon-bedrock.ts +929 -0
  47. package/pi.dev-src/pi-mono/ai/src/providers/anthropic.ts +1125 -0
  48. package/pi.dev-src/pi-mono/ai/src/providers/azure-openai-responses.ts +253 -0
  49. package/pi.dev-src/pi-mono/ai/src/providers/faux.ts +499 -0
  50. package/pi.dev-src/pi-mono/ai/src/providers/github-copilot-headers.ts +37 -0
  51. package/pi.dev-src/pi-mono/ai/src/providers/google-gemini-cli.ts +996 -0
  52. package/pi.dev-src/pi-mono/ai/src/providers/google-shared.ts +354 -0
  53. package/pi.dev-src/pi-mono/ai/src/providers/google-vertex.ts +543 -0
  54. package/pi.dev-src/pi-mono/ai/src/providers/google.ts +500 -0
  55. package/pi.dev-src/pi-mono/ai/src/providers/mistral.ts +629 -0
  56. package/pi.dev-src/pi-mono/ai/src/providers/openai-codex-responses.ts +984 -0
  57. package/pi.dev-src/pi-mono/ai/src/providers/openai-completions.ts +1063 -0
  58. package/pi.dev-src/pi-mono/ai/src/providers/openai-responses-shared.ts +539 -0
  59. package/pi.dev-src/pi-mono/ai/src/providers/openai-responses.ts +264 -0
  60. package/pi.dev-src/pi-mono/ai/src/providers/register-builtins.ts +433 -0
  61. package/pi.dev-src/pi-mono/ai/src/providers/simple-options.ts +47 -0
  62. package/pi.dev-src/pi-mono/ai/src/providers/transform-messages.ts +220 -0
  63. package/pi.dev-src/pi-mono/ai/src/stream.ts +59 -0
  64. package/pi.dev-src/pi-mono/ai/src/types.ts +417 -0
  65. package/pi.dev-src/pi-mono/ai/src/utils/event-stream.ts +87 -0
  66. package/pi.dev-src/pi-mono/ai/src/utils/hash.ts +13 -0
  67. package/pi.dev-src/pi-mono/ai/src/utils/headers.ts +7 -0
  68. package/pi.dev-src/pi-mono/ai/src/utils/json-parse.ts +124 -0
  69. package/pi.dev-src/pi-mono/ai/src/utils/overflow.ts +138 -0
  70. package/pi.dev-src/pi-mono/ai/src/utils/sanitize-unicode.ts +25 -0
  71. package/pi.dev-src/pi-mono/ai/src/utils/typebox-helpers.ts +24 -0
  72. package/pi.dev-src/pi-mono/ai/src/utils/validation.ts +324 -0
  73. package/pi.dev-src/pi-mono/ai/test/abort.test.ts +260 -0
  74. package/pi.dev-src/pi-mono/ai/test/anthropic-oauth.test.ts +99 -0
  75. package/pi.dev-src/pi-mono/ai/test/anthropic-opus-4-7-smoke.test.ts +72 -0
  76. package/pi.dev-src/pi-mono/ai/test/anthropic-sse-parsing.test.ts +113 -0
  77. package/pi.dev-src/pi-mono/ai/test/anthropic-thinking-disable.test.ts +152 -0
  78. package/pi.dev-src/pi-mono/ai/test/anthropic-tool-name-normalization.test.ts +205 -0
  79. package/pi.dev-src/pi-mono/ai/test/azure-utils.ts +28 -0
  80. package/pi.dev-src/pi-mono/ai/test/bedrock-endpoint-resolution.test.ts +131 -0
  81. package/pi.dev-src/pi-mono/ai/test/bedrock-models.test.ts +66 -0
  82. package/pi.dev-src/pi-mono/ai/test/bedrock-thinking-payload.test.ts +107 -0
  83. package/pi.dev-src/pi-mono/ai/test/bedrock-utils.ts +18 -0
  84. package/pi.dev-src/pi-mono/ai/test/cache-retention.test.ts +335 -0
  85. package/pi.dev-src/pi-mono/ai/test/context-overflow.test.ts +714 -0
  86. package/pi.dev-src/pi-mono/ai/test/cross-provider-handoff.test.ts +458 -0
  87. package/pi.dev-src/pi-mono/ai/test/data/red-circle.png +0 -0
  88. package/pi.dev-src/pi-mono/ai/test/empty.test.ts +742 -0
  89. package/pi.dev-src/pi-mono/ai/test/faux-provider.test.ts +597 -0
  90. package/pi.dev-src/pi-mono/ai/test/fireworks-models.test.ts +49 -0
  91. package/pi.dev-src/pi-mono/ai/test/github-copilot-anthropic.test.ts +108 -0
  92. package/pi.dev-src/pi-mono/ai/test/github-copilot-oauth.test.ts +196 -0
  93. package/pi.dev-src/pi-mono/ai/test/google-gemini-cli-claude-thinking-header.test.ts +103 -0
  94. package/pi.dev-src/pi-mono/ai/test/google-gemini-cli-empty-stream.test.ts +108 -0
  95. package/pi.dev-src/pi-mono/ai/test/google-gemini-cli-retry-delay.test.ts +53 -0
  96. package/pi.dev-src/pi-mono/ai/test/google-shared-convert-tools.test.ts +187 -0
  97. package/pi.dev-src/pi-mono/ai/test/google-shared-gemini3-unsigned-tool-call.test.ts +172 -0
  98. package/pi.dev-src/pi-mono/ai/test/google-shared-image-tool-result-routing.test.ts +124 -0
  99. package/pi.dev-src/pi-mono/ai/test/google-thinking-disable.test.ts +183 -0
  100. package/pi.dev-src/pi-mono/ai/test/google-thinking-signature.test.ts +38 -0
  101. package/pi.dev-src/pi-mono/ai/test/google-tool-call-missing-args.test.ts +105 -0
  102. package/pi.dev-src/pi-mono/ai/test/google-vertex-api-key-resolution.test.ts +144 -0
  103. package/pi.dev-src/pi-mono/ai/test/image-tool-result.test.ts +481 -0
  104. package/pi.dev-src/pi-mono/ai/test/interleaved-thinking.test.ts +145 -0
  105. package/pi.dev-src/pi-mono/ai/test/lazy-module-load.test.ts +102 -0
  106. package/pi.dev-src/pi-mono/ai/test/mistral-reasoning-mode.test.ts +66 -0
  107. package/pi.dev-src/pi-mono/ai/test/mistral-tool-schema.test.ts +61 -0
  108. package/pi.dev-src/pi-mono/ai/test/oauth.ts +89 -0
  109. package/pi.dev-src/pi-mono/ai/test/openai-codex-cache-affinity-e2e.test.ts +36 -0
  110. package/pi.dev-src/pi-mono/ai/test/openai-codex-stream.test.ts +686 -0
  111. package/pi.dev-src/pi-mono/ai/test/openai-completions-cache-control-format.test.ts +189 -0
  112. package/pi.dev-src/pi-mono/ai/test/openai-completions-prompt-cache.test.ts +198 -0
  113. package/pi.dev-src/pi-mono/ai/test/openai-completions-thinking-as-text.test.ts +214 -0
  114. package/pi.dev-src/pi-mono/ai/test/openai-completions-tool-choice.test.ts +560 -0
  115. package/pi.dev-src/pi-mono/ai/test/openai-completions-tool-result-images.test.ts +101 -0
  116. package/pi.dev-src/pi-mono/ai/test/openai-responses-cache-affinity-e2e.test.ts +32 -0
  117. package/pi.dev-src/pi-mono/ai/test/openai-responses-copilot-provider.test.ts +128 -0
  118. package/pi.dev-src/pi-mono/ai/test/openai-responses-foreign-toolcall-id.test.ts +66 -0
  119. package/pi.dev-src/pi-mono/ai/test/openai-responses-partial-json-cleanup.test.ts +101 -0
  120. package/pi.dev-src/pi-mono/ai/test/openai-responses-reasoning-replay-e2e.test.ts +292 -0
  121. package/pi.dev-src/pi-mono/ai/test/openai-responses-tool-result-images.test.ts +200 -0
  122. package/pi.dev-src/pi-mono/ai/test/openrouter-cache-write-repro.test.ts +78 -0
  123. package/pi.dev-src/pi-mono/ai/test/overflow.test.ts +64 -0
  124. package/pi.dev-src/pi-mono/ai/test/responseid.test.ts +145 -0
  125. package/pi.dev-src/pi-mono/ai/test/stream.test.ts +1405 -0
  126. package/pi.dev-src/pi-mono/ai/test/supports-xhigh.test.ts +34 -0
  127. package/pi.dev-src/pi-mono/ai/test/tokens.test.ts +304 -0
  128. package/pi.dev-src/pi-mono/ai/test/tool-call-id-normalization.test.ts +291 -0
  129. package/pi.dev-src/pi-mono/ai/test/tool-call-without-result.test.ts +313 -0
  130. package/pi.dev-src/pi-mono/ai/test/total-tokens.test.ts +695 -0
  131. package/pi.dev-src/pi-mono/ai/test/transform-messages-copilot-openai-to-anthropic.test.ts +191 -0
  132. package/pi.dev-src/pi-mono/ai/test/unicode-surrogate.test.ts +754 -0
  133. package/pi.dev-src/pi-mono/ai/test/validation.test.ts +116 -0
  134. package/pi.dev-src/pi-mono/ai/test/xhigh.test.ts +71 -0
  135. package/pi.dev-src/pi-mono/ai/test/zen.test.ts +25 -0
  136. package/pi.dev-src/pi-mono/ai/vitest.config.ts +9 -0
  137. package/pi.dev-src/pi-mono/coding-agent/examples/extensions/antigravity-image-gen.ts +418 -0
  138. package/pi.dev-src/pi-mono/coding-agent/examples/extensions/auto-commit-on-exit.ts +49 -0
  139. package/pi.dev-src/pi-mono/coding-agent/examples/extensions/bash-spawn-hook.ts +30 -0
  140. package/pi.dev-src/pi-mono/coding-agent/examples/extensions/bookmark.ts +50 -0
  141. package/pi.dev-src/pi-mono/coding-agent/examples/extensions/built-in-tool-renderer.ts +249 -0
  142. package/pi.dev-src/pi-mono/coding-agent/examples/extensions/claude-rules.ts +86 -0
  143. package/pi.dev-src/pi-mono/coding-agent/examples/extensions/commands.ts +72 -0
  144. package/pi.dev-src/pi-mono/coding-agent/examples/extensions/confirm-destructive.ts +59 -0
  145. package/pi.dev-src/pi-mono/coding-agent/examples/extensions/custom-compaction.ts +127 -0
  146. package/pi.dev-src/pi-mono/coding-agent/examples/extensions/custom-footer.ts +64 -0
  147. package/pi.dev-src/pi-mono/coding-agent/examples/extensions/custom-header.ts +73 -0
  148. package/pi.dev-src/pi-mono/coding-agent/examples/extensions/custom-provider-anthropic/index.ts +604 -0
  149. package/pi.dev-src/pi-mono/coding-agent/examples/extensions/custom-provider-anthropic/package-lock.json +24 -0
  150. package/pi.dev-src/pi-mono/coding-agent/examples/extensions/custom-provider-gitlab-duo/index.ts +349 -0
  151. package/pi.dev-src/pi-mono/coding-agent/examples/extensions/custom-provider-gitlab-duo/test.ts +82 -0
  152. package/pi.dev-src/pi-mono/coding-agent/examples/extensions/custom-provider-qwen-cli/index.ts +345 -0
  153. package/pi.dev-src/pi-mono/coding-agent/examples/extensions/dirty-repo-guard.ts +56 -0
  154. package/pi.dev-src/pi-mono/coding-agent/examples/extensions/doom-overlay/doom/build.sh +152 -0
  155. package/pi.dev-src/pi-mono/coding-agent/examples/extensions/doom-overlay/doom/doomgeneric_pi.c +72 -0
  156. package/pi.dev-src/pi-mono/coding-agent/examples/extensions/doom-overlay/doom-component.ts +132 -0
  157. package/pi.dev-src/pi-mono/coding-agent/examples/extensions/doom-overlay/doom-engine.ts +173 -0
  158. package/pi.dev-src/pi-mono/coding-agent/examples/extensions/doom-overlay/doom-keys.ts +104 -0
  159. package/pi.dev-src/pi-mono/coding-agent/examples/extensions/doom-overlay/index.ts +74 -0
  160. package/pi.dev-src/pi-mono/coding-agent/examples/extensions/doom-overlay/wad-finder.ts +51 -0
  161. package/pi.dev-src/pi-mono/coding-agent/examples/extensions/dynamic-resources/SKILL.md +8 -0
  162. package/pi.dev-src/pi-mono/coding-agent/examples/extensions/dynamic-resources/dynamic.json +79 -0
  163. package/pi.dev-src/pi-mono/coding-agent/examples/extensions/dynamic-resources/dynamic.md +5 -0
  164. package/pi.dev-src/pi-mono/coding-agent/examples/extensions/dynamic-resources/index.ts +15 -0
  165. package/pi.dev-src/pi-mono/coding-agent/examples/extensions/dynamic-tools.ts +74 -0
  166. package/pi.dev-src/pi-mono/coding-agent/examples/extensions/event-bus.ts +43 -0
  167. package/pi.dev-src/pi-mono/coding-agent/examples/extensions/file-trigger.ts +41 -0
  168. package/pi.dev-src/pi-mono/coding-agent/examples/extensions/git-checkpoint.ts +53 -0
  169. package/pi.dev-src/pi-mono/coding-agent/examples/extensions/github-issue-autocomplete.ts +185 -0
  170. package/pi.dev-src/pi-mono/coding-agent/examples/extensions/handoff.ts +153 -0
  171. package/pi.dev-src/pi-mono/coding-agent/examples/extensions/hello.ts +26 -0
  172. package/pi.dev-src/pi-mono/coding-agent/examples/extensions/hidden-thinking-label.ts +53 -0
  173. package/pi.dev-src/pi-mono/coding-agent/examples/extensions/inline-bash.ts +94 -0
  174. package/pi.dev-src/pi-mono/coding-agent/examples/extensions/input-transform.ts +43 -0
  175. package/pi.dev-src/pi-mono/coding-agent/examples/extensions/interactive-shell.ts +196 -0
  176. package/pi.dev-src/pi-mono/coding-agent/examples/extensions/mac-system-theme.ts +47 -0
  177. package/pi.dev-src/pi-mono/coding-agent/examples/extensions/message-renderer.ts +59 -0
  178. package/pi.dev-src/pi-mono/coding-agent/examples/extensions/minimal-mode.ts +426 -0
  179. package/pi.dev-src/pi-mono/coding-agent/examples/extensions/modal-editor.ts +85 -0
  180. package/pi.dev-src/pi-mono/coding-agent/examples/extensions/model-status.ts +31 -0
  181. package/pi.dev-src/pi-mono/coding-agent/examples/extensions/notify.ts +55 -0
  182. package/pi.dev-src/pi-mono/coding-agent/examples/extensions/overlay-qa-tests.ts +1348 -0
  183. package/pi.dev-src/pi-mono/coding-agent/examples/extensions/overlay-test.ts +150 -0
  184. package/pi.dev-src/pi-mono/coding-agent/examples/extensions/permission-gate.ts +34 -0
  185. package/pi.dev-src/pi-mono/coding-agent/examples/extensions/pirate.ts +47 -0
  186. package/pi.dev-src/pi-mono/coding-agent/examples/extensions/plan-mode/index.ts +340 -0
  187. package/pi.dev-src/pi-mono/coding-agent/examples/extensions/plan-mode/utils.ts +168 -0
  188. package/pi.dev-src/pi-mono/coding-agent/examples/extensions/preset.ts +430 -0
  189. package/pi.dev-src/pi-mono/coding-agent/examples/extensions/prompt-customizer.ts +97 -0
  190. package/pi.dev-src/pi-mono/coding-agent/examples/extensions/protected-paths.ts +30 -0
  191. package/pi.dev-src/pi-mono/coding-agent/examples/extensions/provider-payload.ts +18 -0
  192. package/pi.dev-src/pi-mono/coding-agent/examples/extensions/qna.ts +122 -0
  193. package/pi.dev-src/pi-mono/coding-agent/examples/extensions/question.ts +264 -0
  194. package/pi.dev-src/pi-mono/coding-agent/examples/extensions/questionnaire.ts +427 -0
  195. package/pi.dev-src/pi-mono/coding-agent/examples/extensions/rainbow-editor.ts +88 -0
  196. package/pi.dev-src/pi-mono/coding-agent/examples/extensions/reload-runtime.ts +37 -0
  197. package/pi.dev-src/pi-mono/coding-agent/examples/extensions/rpc-demo.ts +118 -0
  198. package/pi.dev-src/pi-mono/coding-agent/examples/extensions/sandbox/index.ts +321 -0
  199. package/pi.dev-src/pi-mono/coding-agent/examples/extensions/sandbox/package-lock.json +92 -0
  200. package/pi.dev-src/pi-mono/coding-agent/examples/extensions/send-user-message.ts +97 -0
  201. package/pi.dev-src/pi-mono/coding-agent/examples/extensions/session-name.ts +27 -0
  202. package/pi.dev-src/pi-mono/coding-agent/examples/extensions/shutdown-command.ts +63 -0
  203. package/pi.dev-src/pi-mono/coding-agent/examples/extensions/snake.ts +343 -0
  204. package/pi.dev-src/pi-mono/coding-agent/examples/extensions/space-invaders.ts +560 -0
  205. package/pi.dev-src/pi-mono/coding-agent/examples/extensions/ssh.ts +220 -0
  206. package/pi.dev-src/pi-mono/coding-agent/examples/extensions/status-line.ts +32 -0
  207. package/pi.dev-src/pi-mono/coding-agent/examples/extensions/structured-output.ts +65 -0
  208. package/pi.dev-src/pi-mono/coding-agent/examples/extensions/subagent/agents/planner.md +37 -0
  209. package/pi.dev-src/pi-mono/coding-agent/examples/extensions/subagent/agents/reviewer.md +35 -0
  210. package/pi.dev-src/pi-mono/coding-agent/examples/extensions/subagent/agents/scout.md +50 -0
  211. package/pi.dev-src/pi-mono/coding-agent/examples/extensions/subagent/agents/worker.md +24 -0
  212. package/pi.dev-src/pi-mono/coding-agent/examples/extensions/subagent/agents.ts +126 -0
  213. package/pi.dev-src/pi-mono/coding-agent/examples/extensions/subagent/index.ts +987 -0
  214. package/pi.dev-src/pi-mono/coding-agent/examples/extensions/subagent/prompts/implement-and-review.md +10 -0
  215. package/pi.dev-src/pi-mono/coding-agent/examples/extensions/subagent/prompts/implement.md +10 -0
  216. package/pi.dev-src/pi-mono/coding-agent/examples/extensions/subagent/prompts/scout-and-plan.md +9 -0
  217. package/pi.dev-src/pi-mono/coding-agent/examples/extensions/summarize.ts +206 -0
  218. package/pi.dev-src/pi-mono/coding-agent/examples/extensions/system-prompt-header.ts +17 -0
  219. package/pi.dev-src/pi-mono/coding-agent/examples/extensions/tic-tac-toe.ts +1008 -0
  220. package/pi.dev-src/pi-mono/coding-agent/examples/extensions/timed-confirm.ts +70 -0
  221. package/pi.dev-src/pi-mono/coding-agent/examples/extensions/titlebar-spinner.ts +58 -0
  222. package/pi.dev-src/pi-mono/coding-agent/examples/extensions/todo.ts +297 -0
  223. package/pi.dev-src/pi-mono/coding-agent/examples/extensions/tool-override.ts +144 -0
  224. package/pi.dev-src/pi-mono/coding-agent/examples/extensions/tools.ts +141 -0
  225. package/pi.dev-src/pi-mono/coding-agent/examples/extensions/trigger-compact.ts +50 -0
  226. package/pi.dev-src/pi-mono/coding-agent/examples/extensions/truncated-tool.ts +195 -0
  227. package/pi.dev-src/pi-mono/coding-agent/examples/extensions/widget-placement.ts +9 -0
  228. package/pi.dev-src/pi-mono/coding-agent/examples/extensions/with-deps/index.ts +32 -0
  229. package/pi.dev-src/pi-mono/coding-agent/examples/extensions/with-deps/package-lock.json +31 -0
  230. package/pi.dev-src/pi-mono/coding-agent/examples/extensions/working-indicator.ts +123 -0
  231. package/pi.dev-src/pi-mono/coding-agent/examples/extensions/working-message-test.ts +25 -0
  232. package/pi.dev-src/pi-mono/coding-agent/examples/rpc-extension-ui.ts +632 -0
  233. package/pi.dev-src/pi-mono/coding-agent/examples/sdk/01-minimal.ts +22 -0
  234. package/pi.dev-src/pi-mono/coding-agent/examples/sdk/02-custom-model.ts +49 -0
  235. package/pi.dev-src/pi-mono/coding-agent/examples/sdk/03-custom-prompt.ts +62 -0
  236. package/pi.dev-src/pi-mono/coding-agent/examples/sdk/04-skills.ts +55 -0
  237. package/pi.dev-src/pi-mono/coding-agent/examples/sdk/05-tools.ts +44 -0
  238. package/pi.dev-src/pi-mono/coding-agent/examples/sdk/06-extensions.ts +90 -0
  239. package/pi.dev-src/pi-mono/coding-agent/examples/sdk/07-context-files.ts +42 -0
  240. package/pi.dev-src/pi-mono/coding-agent/examples/sdk/08-prompt-templates.ts +51 -0
  241. package/pi.dev-src/pi-mono/coding-agent/examples/sdk/09-api-keys-and-oauth.ts +48 -0
  242. package/pi.dev-src/pi-mono/coding-agent/examples/sdk/10-settings.ts +53 -0
  243. package/pi.dev-src/pi-mono/coding-agent/examples/sdk/11-sessions.ts +48 -0
  244. package/pi.dev-src/pi-mono/coding-agent/examples/sdk/12-full-control.ts +73 -0
  245. package/pi.dev-src/pi-mono/coding-agent/examples/sdk/13-session-runtime.ts +67 -0
  246. package/pi.dev-src/pi-mono/coding-agent/scripts/migrate-sessions.sh +93 -0
  247. package/pi.dev-src/pi-mono/coding-agent/src/bun/cli.ts +6 -0
  248. package/pi.dev-src/pi-mono/coding-agent/src/bun/register-bedrock.ts +4 -0
  249. package/pi.dev-src/pi-mono/coding-agent/src/cli/args.ts +335 -0
  250. package/pi.dev-src/pi-mono/coding-agent/src/cli/config-selector.ts +52 -0
  251. package/pi.dev-src/pi-mono/coding-agent/src/cli/file-processor.ts +100 -0
  252. package/pi.dev-src/pi-mono/coding-agent/src/cli/initial-message.ts +43 -0
  253. package/pi.dev-src/pi-mono/coding-agent/src/cli/list-models.ts +110 -0
  254. package/pi.dev-src/pi-mono/coding-agent/src/cli/session-picker.ts +52 -0
  255. package/pi.dev-src/pi-mono/coding-agent/src/cli.ts +17 -0
  256. package/pi.dev-src/pi-mono/coding-agent/src/config.ts +261 -0
  257. package/pi.dev-src/pi-mono/coding-agent/src/core/agent-session-runtime.ts +394 -0
  258. package/pi.dev-src/pi-mono/coding-agent/src/core/agent-session-services.ts +196 -0
  259. package/pi.dev-src/pi-mono/coding-agent/src/core/agent-session.ts +3096 -0
  260. package/pi.dev-src/pi-mono/coding-agent/src/core/auth-storage.ts +493 -0
  261. package/pi.dev-src/pi-mono/coding-agent/src/core/bash-executor.ts +154 -0
  262. package/pi.dev-src/pi-mono/coding-agent/src/core/compaction/branch-summarization.ts +355 -0
  263. package/pi.dev-src/pi-mono/coding-agent/src/core/compaction/compaction.ts +839 -0
  264. package/pi.dev-src/pi-mono/coding-agent/src/core/compaction/index.ts +7 -0
  265. package/pi.dev-src/pi-mono/coding-agent/src/core/compaction/utils.ts +170 -0
  266. package/pi.dev-src/pi-mono/coding-agent/src/core/defaults.ts +3 -0
  267. package/pi.dev-src/pi-mono/coding-agent/src/core/diagnostics.ts +15 -0
  268. package/pi.dev-src/pi-mono/coding-agent/src/core/event-bus.ts +33 -0
  269. package/pi.dev-src/pi-mono/coding-agent/src/core/exec.ts +107 -0
  270. package/pi.dev-src/pi-mono/coding-agent/src/core/export-html/ansi-to-html.ts +258 -0
  271. package/pi.dev-src/pi-mono/coding-agent/src/core/export-html/index.ts +314 -0
  272. package/pi.dev-src/pi-mono/coding-agent/src/core/export-html/template.css +1017 -0
  273. package/pi.dev-src/pi-mono/coding-agent/src/core/export-html/template.html +55 -0
  274. package/pi.dev-src/pi-mono/coding-agent/src/core/export-html/template.js +1770 -0
  275. package/pi.dev-src/pi-mono/coding-agent/src/core/export-html/tool-renderer.ts +158 -0
  276. package/pi.dev-src/pi-mono/coding-agent/src/core/export-html/vendor/highlight.min.js +1213 -0
  277. package/pi.dev-src/pi-mono/coding-agent/src/core/export-html/vendor/marked.min.js +6 -0
  278. package/pi.dev-src/pi-mono/coding-agent/src/core/extensions/index.ts +171 -0
  279. package/pi.dev-src/pi-mono/coding-agent/src/core/extensions/loader.ts +606 -0
  280. package/pi.dev-src/pi-mono/coding-agent/src/core/extensions/runner.ts +1019 -0
  281. package/pi.dev-src/pi-mono/coding-agent/src/core/extensions/types.ts +1542 -0
  282. package/pi.dev-src/pi-mono/coding-agent/src/core/extensions/wrapper.ts +30 -0
  283. package/pi.dev-src/pi-mono/coding-agent/src/core/footer-data-provider.ts +354 -0
  284. package/pi.dev-src/pi-mono/coding-agent/src/core/index.ts +77 -0
  285. package/pi.dev-src/pi-mono/coding-agent/src/core/keybindings.ts +370 -0
  286. package/pi.dev-src/pi-mono/coding-agent/src/core/messages.ts +195 -0
  287. package/pi.dev-src/pi-mono/coding-agent/src/core/model-registry.ts +858 -0
  288. package/pi.dev-src/pi-mono/coding-agent/src/core/model-resolver.ts +629 -0
  289. package/pi.dev-src/pi-mono/coding-agent/src/core/output-guard.ts +74 -0
  290. package/pi.dev-src/pi-mono/coding-agent/src/core/package-manager.ts +2408 -0
  291. package/pi.dev-src/pi-mono/coding-agent/src/core/prompt-templates.ts +296 -0
  292. package/pi.dev-src/pi-mono/coding-agent/src/core/resolve-config-value.ts +142 -0
  293. package/pi.dev-src/pi-mono/coding-agent/src/core/resource-loader.ts +917 -0
  294. package/pi.dev-src/pi-mono/coding-agent/src/core/sdk.ts +380 -0
  295. package/pi.dev-src/pi-mono/coding-agent/src/core/session-cwd.ts +59 -0
  296. package/pi.dev-src/pi-mono/coding-agent/src/core/session-manager.ts +1425 -0
  297. package/pi.dev-src/pi-mono/coding-agent/src/core/settings-manager.ts +999 -0
  298. package/pi.dev-src/pi-mono/coding-agent/src/core/skills.ts +508 -0
  299. package/pi.dev-src/pi-mono/coding-agent/src/core/slash-commands.ts +39 -0
  300. package/pi.dev-src/pi-mono/coding-agent/src/core/source-info.ts +40 -0
  301. package/pi.dev-src/pi-mono/coding-agent/src/core/system-prompt.ts +172 -0
  302. package/pi.dev-src/pi-mono/coding-agent/src/core/telemetry.ts +13 -0
  303. package/pi.dev-src/pi-mono/coding-agent/src/core/timings.ts +31 -0
  304. package/pi.dev-src/pi-mono/coding-agent/src/core/tools/bash.ts +448 -0
  305. package/pi.dev-src/pi-mono/coding-agent/src/core/tools/edit-diff.ts +445 -0
  306. package/pi.dev-src/pi-mono/coding-agent/src/core/tools/edit.ts +487 -0
  307. package/pi.dev-src/pi-mono/coding-agent/src/core/tools/file-mutation-queue.ts +39 -0
  308. package/pi.dev-src/pi-mono/coding-agent/src/core/tools/find.ts +370 -0
  309. package/pi.dev-src/pi-mono/coding-agent/src/core/tools/grep.ts +384 -0
  310. package/pi.dev-src/pi-mono/coding-agent/src/core/tools/index.ts +196 -0
  311. package/pi.dev-src/pi-mono/coding-agent/src/core/tools/ls.ts +229 -0
  312. package/pi.dev-src/pi-mono/coding-agent/src/core/tools/path-utils.ts +94 -0
  313. package/pi.dev-src/pi-mono/coding-agent/src/core/tools/read.ts +273 -0
  314. package/pi.dev-src/pi-mono/coding-agent/src/core/tools/render-utils.ts +64 -0
  315. package/pi.dev-src/pi-mono/coding-agent/src/core/tools/tool-definition-wrapper.ts +45 -0
  316. package/pi.dev-src/pi-mono/coding-agent/src/core/tools/truncate.ts +265 -0
  317. package/pi.dev-src/pi-mono/coding-agent/src/core/tools/write.ts +281 -0
  318. package/pi.dev-src/pi-mono/coding-agent/src/index.ts +352 -0
  319. package/pi.dev-src/pi-mono/coding-agent/src/main.ts +731 -0
  320. package/pi.dev-src/pi-mono/coding-agent/src/migrations.ts +314 -0
  321. package/pi.dev-src/pi-mono/coding-agent/src/modes/index.ts +9 -0
  322. package/pi.dev-src/pi-mono/coding-agent/src/modes/interactive/assets/clankolas.png +0 -0
  323. package/pi.dev-src/pi-mono/coding-agent/src/modes/interactive/components/armin.ts +382 -0
  324. package/pi.dev-src/pi-mono/coding-agent/src/modes/interactive/components/assistant-message.ts +147 -0
  325. package/pi.dev-src/pi-mono/coding-agent/src/modes/interactive/components/bash-execution.ts +218 -0
  326. package/pi.dev-src/pi-mono/coding-agent/src/modes/interactive/components/bordered-loader.ts +68 -0
  327. package/pi.dev-src/pi-mono/coding-agent/src/modes/interactive/components/branch-summary-message.ts +58 -0
  328. package/pi.dev-src/pi-mono/coding-agent/src/modes/interactive/components/compaction-summary-message.ts +59 -0
  329. package/pi.dev-src/pi-mono/coding-agent/src/modes/interactive/components/config-selector.ts +592 -0
  330. package/pi.dev-src/pi-mono/coding-agent/src/modes/interactive/components/countdown-timer.ts +38 -0
  331. package/pi.dev-src/pi-mono/coding-agent/src/modes/interactive/components/custom-editor.ts +80 -0
  332. package/pi.dev-src/pi-mono/coding-agent/src/modes/interactive/components/custom-message.ts +99 -0
  333. package/pi.dev-src/pi-mono/coding-agent/src/modes/interactive/components/daxnuts.ts +164 -0
  334. package/pi.dev-src/pi-mono/coding-agent/src/modes/interactive/components/diff.ts +147 -0
  335. package/pi.dev-src/pi-mono/coding-agent/src/modes/interactive/components/dynamic-border.ts +25 -0
  336. package/pi.dev-src/pi-mono/coding-agent/src/modes/interactive/components/earendil-announcement.ts +53 -0
  337. package/pi.dev-src/pi-mono/coding-agent/src/modes/interactive/components/extension-editor.ts +147 -0
  338. package/pi.dev-src/pi-mono/coding-agent/src/modes/interactive/components/extension-input.ts +87 -0
  339. package/pi.dev-src/pi-mono/coding-agent/src/modes/interactive/components/extension-selector.ts +107 -0
  340. package/pi.dev-src/pi-mono/coding-agent/src/modes/interactive/components/footer.ts +220 -0
  341. package/pi.dev-src/pi-mono/coding-agent/src/modes/interactive/components/index.ts +32 -0
  342. package/pi.dev-src/pi-mono/coding-agent/src/modes/interactive/components/keybinding-hints.ts +24 -0
  343. package/pi.dev-src/pi-mono/coding-agent/src/modes/interactive/components/login-dialog.ts +178 -0
  344. package/pi.dev-src/pi-mono/coding-agent/src/modes/interactive/components/model-selector.ts +338 -0
  345. package/pi.dev-src/pi-mono/coding-agent/src/modes/interactive/components/oauth-selector.ts +121 -0
  346. package/pi.dev-src/pi-mono/coding-agent/src/modes/interactive/components/scoped-models-selector.ts +355 -0
  347. package/pi.dev-src/pi-mono/coding-agent/src/modes/interactive/components/session-selector-search.ts +194 -0
  348. package/pi.dev-src/pi-mono/coding-agent/src/modes/interactive/components/session-selector.ts +1026 -0
  349. package/pi.dev-src/pi-mono/coding-agent/src/modes/interactive/components/settings-selector.ts +456 -0
  350. package/pi.dev-src/pi-mono/coding-agent/src/modes/interactive/components/show-images-selector.ts +50 -0
  351. package/pi.dev-src/pi-mono/coding-agent/src/modes/interactive/components/skill-invocation-message.ts +55 -0
  352. package/pi.dev-src/pi-mono/coding-agent/src/modes/interactive/components/theme-selector.ts +67 -0
  353. package/pi.dev-src/pi-mono/coding-agent/src/modes/interactive/components/thinking-selector.ts +74 -0
  354. package/pi.dev-src/pi-mono/coding-agent/src/modes/interactive/components/tool-execution.ts +352 -0
  355. package/pi.dev-src/pi-mono/coding-agent/src/modes/interactive/components/tree-selector.ts +1246 -0
  356. package/pi.dev-src/pi-mono/coding-agent/src/modes/interactive/components/user-message-selector.ts +155 -0
  357. package/pi.dev-src/pi-mono/coding-agent/src/modes/interactive/components/user-message.ts +35 -0
  358. package/pi.dev-src/pi-mono/coding-agent/src/modes/interactive/components/visual-truncate.ts +50 -0
  359. package/pi.dev-src/pi-mono/coding-agent/src/modes/interactive/interactive-mode.ts +5155 -0
  360. package/pi.dev-src/pi-mono/coding-agent/src/modes/interactive/theme/dark.json +85 -0
  361. package/pi.dev-src/pi-mono/coding-agent/src/modes/interactive/theme/light.json +84 -0
  362. package/pi.dev-src/pi-mono/coding-agent/src/modes/interactive/theme/theme-schema.json +335 -0
  363. package/pi.dev-src/pi-mono/coding-agent/src/modes/interactive/theme/theme.ts +1141 -0
  364. package/pi.dev-src/pi-mono/coding-agent/src/modes/print-mode.ts +158 -0
  365. package/pi.dev-src/pi-mono/coding-agent/src/modes/rpc/jsonl.ts +58 -0
  366. package/pi.dev-src/pi-mono/coding-agent/src/modes/rpc/rpc-client.ts +515 -0
  367. package/pi.dev-src/pi-mono/coding-agent/src/modes/rpc/rpc-mode.ts +745 -0
  368. package/pi.dev-src/pi-mono/coding-agent/src/modes/rpc/rpc-types.ts +264 -0
  369. package/pi.dev-src/pi-mono/coding-agent/src/package-manager-cli.ts +270 -0
  370. package/pi.dev-src/pi-mono/coding-agent/src/utils/changelog.ts +99 -0
  371. package/pi.dev-src/pi-mono/coding-agent/src/utils/child-process.ts +86 -0
  372. package/pi.dev-src/pi-mono/coding-agent/src/utils/clipboard-image.ts +300 -0
  373. package/pi.dev-src/pi-mono/coding-agent/src/utils/clipboard-native.ts +22 -0
  374. package/pi.dev-src/pi-mono/coding-agent/src/utils/clipboard.ts +81 -0
  375. package/pi.dev-src/pi-mono/coding-agent/src/utils/exif-orientation.ts +183 -0
  376. package/pi.dev-src/pi-mono/coding-agent/src/utils/frontmatter.ts +39 -0
  377. package/pi.dev-src/pi-mono/coding-agent/src/utils/fs-watch.ts +30 -0
  378. package/pi.dev-src/pi-mono/coding-agent/src/utils/git.ts +192 -0
  379. package/pi.dev-src/pi-mono/coding-agent/src/utils/image-convert.ts +41 -0
  380. package/pi.dev-src/pi-mono/coding-agent/src/utils/image-resize.ts +176 -0
  381. package/pi.dev-src/pi-mono/coding-agent/src/utils/mime.ts +30 -0
  382. package/pi.dev-src/pi-mono/coding-agent/src/utils/paths.ts +20 -0
  383. package/pi.dev-src/pi-mono/coding-agent/src/utils/photon.ts +139 -0
  384. package/pi.dev-src/pi-mono/coding-agent/src/utils/shell.ts +210 -0
  385. package/pi.dev-src/pi-mono/coding-agent/src/utils/sleep.ts +18 -0
  386. package/pi.dev-src/pi-mono/coding-agent/src/utils/tools-manager.ts +287 -0
  387. package/pi.dev-src/pi-mono/coding-agent/test/agent-session-auto-compaction-queue.test.ts +437 -0
  388. package/pi.dev-src/pi-mono/coding-agent/test/agent-session-branching.test.ts +156 -0
  389. package/pi.dev-src/pi-mono/coding-agent/test/agent-session-compaction.test.ts +208 -0
  390. package/pi.dev-src/pi-mono/coding-agent/test/agent-session-concurrent.test.ts +618 -0
  391. package/pi.dev-src/pi-mono/coding-agent/test/agent-session-dynamic-provider.test.ts +117 -0
  392. package/pi.dev-src/pi-mono/coding-agent/test/agent-session-dynamic-tools.test.ts +182 -0
  393. package/pi.dev-src/pi-mono/coding-agent/test/agent-session-retry.test.ts +318 -0
  394. package/pi.dev-src/pi-mono/coding-agent/test/agent-session-runtime-events.test.ts +208 -0
  395. package/pi.dev-src/pi-mono/coding-agent/test/agent-session-stats.test.ts +143 -0
  396. package/pi.dev-src/pi-mono/coding-agent/test/agent-session-tree-navigation.test.ts +323 -0
  397. package/pi.dev-src/pi-mono/coding-agent/test/args.test.ts +328 -0
  398. package/pi.dev-src/pi-mono/coding-agent/test/assistant-message.test.ts +57 -0
  399. package/pi.dev-src/pi-mono/coding-agent/test/auth-storage.test.ts +462 -0
  400. package/pi.dev-src/pi-mono/coding-agent/test/bash-close-hang-windows.test.ts +126 -0
  401. package/pi.dev-src/pi-mono/coding-agent/test/bash-execution-width.test.ts +80 -0
  402. package/pi.dev-src/pi-mono/coding-agent/test/block-images.test.ts +120 -0
  403. package/pi.dev-src/pi-mono/coding-agent/test/clipboard-image-bmp-conversion.test.ts +88 -0
  404. package/pi.dev-src/pi-mono/coding-agent/test/clipboard-image.test.ts +136 -0
  405. package/pi.dev-src/pi-mono/coding-agent/test/compaction-extensions-example.test.ts +66 -0
  406. package/pi.dev-src/pi-mono/coding-agent/test/compaction-extensions.test.ts +415 -0
  407. package/pi.dev-src/pi-mono/coding-agent/test/compaction-serialization.test.ts +79 -0
  408. package/pi.dev-src/pi-mono/coding-agent/test/compaction-summary-reasoning.test.ts +118 -0
  409. package/pi.dev-src/pi-mono/coding-agent/test/compaction-thinking-model.test.ts +223 -0
  410. package/pi.dev-src/pi-mono/coding-agent/test/compaction.test.ts +550 -0
  411. package/pi.dev-src/pi-mono/coding-agent/test/config.test.ts +30 -0
  412. package/pi.dev-src/pi-mono/coding-agent/test/edit-tool-legacy-input.test.ts +116 -0
  413. package/pi.dev-src/pi-mono/coding-agent/test/edit-tool-no-full-redraw.test.ts +235 -0
  414. package/pi.dev-src/pi-mono/coding-agent/test/export-html-whitespace.test.ts +12 -0
  415. package/pi.dev-src/pi-mono/coding-agent/test/export-html-xss.test.ts +28 -0
  416. package/pi.dev-src/pi-mono/coding-agent/test/extensions-discovery.test.ts +463 -0
  417. package/pi.dev-src/pi-mono/coding-agent/test/extensions-input-event.test.ts +112 -0
  418. package/pi.dev-src/pi-mono/coding-agent/test/extensions-runner.test.ts +805 -0
  419. package/pi.dev-src/pi-mono/coding-agent/test/file-mutation-queue.test.ts +163 -0
  420. package/pi.dev-src/pi-mono/coding-agent/test/fixtures/assistant-message-with-thinking-code.json +33 -0
  421. package/pi.dev-src/pi-mono/coding-agent/test/fixtures/before-compaction.jsonl +1003 -0
  422. package/pi.dev-src/pi-mono/coding-agent/test/fixtures/empty-agent/.gitkeep +0 -0
  423. package/pi.dev-src/pi-mono/coding-agent/test/fixtures/empty-cwd/.gitkeep +0 -0
  424. package/pi.dev-src/pi-mono/coding-agent/test/fixtures/large-session.jsonl +1019 -0
  425. package/pi.dev-src/pi-mono/coding-agent/test/fixtures/skills/consecutive-hyphens/SKILL.md +8 -0
  426. package/pi.dev-src/pi-mono/coding-agent/test/fixtures/skills/disable-model-invocation/SKILL.md +9 -0
  427. package/pi.dev-src/pi-mono/coding-agent/test/fixtures/skills/invalid-name-chars/SKILL.md +8 -0
  428. package/pi.dev-src/pi-mono/coding-agent/test/fixtures/skills/invalid-yaml/SKILL.md +8 -0
  429. package/pi.dev-src/pi-mono/coding-agent/test/fixtures/skills/long-name/SKILL.md +8 -0
  430. package/pi.dev-src/pi-mono/coding-agent/test/fixtures/skills/missing-description/SKILL.md +7 -0
  431. package/pi.dev-src/pi-mono/coding-agent/test/fixtures/skills/multiline-description/SKILL.md +11 -0
  432. package/pi.dev-src/pi-mono/coding-agent/test/fixtures/skills/name-mismatch/SKILL.md +8 -0
  433. package/pi.dev-src/pi-mono/coding-agent/test/fixtures/skills/nested/child-skill/SKILL.md +8 -0
  434. package/pi.dev-src/pi-mono/coding-agent/test/fixtures/skills/no-frontmatter/SKILL.md +3 -0
  435. package/pi.dev-src/pi-mono/coding-agent/test/fixtures/skills/root-skill-preferred/SKILL.md +3 -0
  436. package/pi.dev-src/pi-mono/coding-agent/test/fixtures/skills/root-skill-preferred/nested-child/SKILL.md +3 -0
  437. package/pi.dev-src/pi-mono/coding-agent/test/fixtures/skills/unknown-field/SKILL.md +10 -0
  438. package/pi.dev-src/pi-mono/coding-agent/test/fixtures/skills/valid-skill/SKILL.md +8 -0
  439. package/pi.dev-src/pi-mono/coding-agent/test/fixtures/skills-collision/first/calendar/SKILL.md +8 -0
  440. package/pi.dev-src/pi-mono/coding-agent/test/fixtures/skills-collision/second/calendar/SKILL.md +8 -0
  441. package/pi.dev-src/pi-mono/coding-agent/test/footer-data-provider.test.ts +265 -0
  442. package/pi.dev-src/pi-mono/coding-agent/test/footer-width.test.ts +115 -0
  443. package/pi.dev-src/pi-mono/coding-agent/test/frontmatter.test.ts +60 -0
  444. package/pi.dev-src/pi-mono/coding-agent/test/git-ssh-url.test.ts +78 -0
  445. package/pi.dev-src/pi-mono/coding-agent/test/git-update.test.ts +411 -0
  446. package/pi.dev-src/pi-mono/coding-agent/test/image-processing.test.ts +145 -0
  447. package/pi.dev-src/pi-mono/coding-agent/test/image-resize-callers.test.ts +53 -0
  448. package/pi.dev-src/pi-mono/coding-agent/test/initial-message.test.ts +48 -0
  449. package/pi.dev-src/pi-mono/coding-agent/test/interactive-mode-anthropic-warning.test.ts +73 -0
  450. package/pi.dev-src/pi-mono/coding-agent/test/interactive-mode-clone-command.test.ts +77 -0
  451. package/pi.dev-src/pi-mono/coding-agent/test/interactive-mode-compaction.test.ts +57 -0
  452. package/pi.dev-src/pi-mono/coding-agent/test/interactive-mode-import-command.test.ts +144 -0
  453. package/pi.dev-src/pi-mono/coding-agent/test/interactive-mode-status.test.ts +635 -0
  454. package/pi.dev-src/pi-mono/coding-agent/test/interactive-mode-suspend.test.ts +149 -0
  455. package/pi.dev-src/pi-mono/coding-agent/test/keybindings-migration.test.ts +88 -0
  456. package/pi.dev-src/pi-mono/coding-agent/test/model-registry.test.ts +1209 -0
  457. package/pi.dev-src/pi-mono/coding-agent/test/model-resolver.test.ts +436 -0
  458. package/pi.dev-src/pi-mono/coding-agent/test/package-command-paths.test.ts +144 -0
  459. package/pi.dev-src/pi-mono/coding-agent/test/package-manager-ssh.test.ts +97 -0
  460. package/pi.dev-src/pi-mono/coding-agent/test/package-manager.test.ts +1795 -0
  461. package/pi.dev-src/pi-mono/coding-agent/test/path-utils.test.ts +162 -0
  462. package/pi.dev-src/pi-mono/coding-agent/test/plan-mode-utils.test.ts +261 -0
  463. package/pi.dev-src/pi-mono/coding-agent/test/print-mode.test.ts +140 -0
  464. package/pi.dev-src/pi-mono/coding-agent/test/prompt-templates.test.ts +515 -0
  465. package/pi.dev-src/pi-mono/coding-agent/test/resource-loader.test.ts +576 -0
  466. package/pi.dev-src/pi-mono/coding-agent/test/rpc-client-clone.test.ts +29 -0
  467. package/pi.dev-src/pi-mono/coding-agent/test/rpc-example.ts +86 -0
  468. package/pi.dev-src/pi-mono/coding-agent/test/rpc-jsonl.test.ts +65 -0
  469. package/pi.dev-src/pi-mono/coding-agent/test/rpc-prompt-response-semantics.test.ts +285 -0
  470. package/pi.dev-src/pi-mono/coding-agent/test/rpc.test.ts +321 -0
  471. package/pi.dev-src/pi-mono/coding-agent/test/sdk-codex-cache-probe-tool-loop.ts +364 -0
  472. package/pi.dev-src/pi-mono/coding-agent/test/sdk-openrouter-attribution.test.ts +181 -0
  473. package/pi.dev-src/pi-mono/coding-agent/test/sdk-session-manager.test.ts +95 -0
  474. package/pi.dev-src/pi-mono/coding-agent/test/sdk-skills.test.ts +109 -0
  475. package/pi.dev-src/pi-mono/coding-agent/test/session-cwd.test.ts +91 -0
  476. package/pi.dev-src/pi-mono/coding-agent/test/session-info-modified-timestamp.test.ts +83 -0
  477. package/pi.dev-src/pi-mono/coding-agent/test/session-manager/build-context.test.ts +268 -0
  478. package/pi.dev-src/pi-mono/coding-agent/test/session-manager/custom-session-id.test.ts +109 -0
  479. package/pi.dev-src/pi-mono/coding-agent/test/session-manager/file-operations.test.ts +208 -0
  480. package/pi.dev-src/pi-mono/coding-agent/test/session-manager/labels.test.ts +198 -0
  481. package/pi.dev-src/pi-mono/coding-agent/test/session-manager/migration.test.ts +78 -0
  482. package/pi.dev-src/pi-mono/coding-agent/test/session-manager/save-entry.test.ts +55 -0
  483. package/pi.dev-src/pi-mono/coding-agent/test/session-manager/tree-traversal.test.ts +533 -0
  484. package/pi.dev-src/pi-mono/coding-agent/test/session-selector-path-delete.test.ts +315 -0
  485. package/pi.dev-src/pi-mono/coding-agent/test/session-selector-rename.test.ts +111 -0
  486. package/pi.dev-src/pi-mono/coding-agent/test/session-selector-search.test.ts +195 -0
  487. package/pi.dev-src/pi-mono/coding-agent/test/settings-manager-bug.test.ts +147 -0
  488. package/pi.dev-src/pi-mono/coding-agent/test/settings-manager.test.ts +319 -0
  489. package/pi.dev-src/pi-mono/coding-agent/test/skills.test.ts +432 -0
  490. package/pi.dev-src/pi-mono/coding-agent/test/stdout-cleanliness.test.ts +103 -0
  491. package/pi.dev-src/pi-mono/coding-agent/test/streaming-render-debug.ts +97 -0
  492. package/pi.dev-src/pi-mono/coding-agent/test/suite/agent-session-bash-persistence.test.ts +242 -0
  493. package/pi.dev-src/pi-mono/coding-agent/test/suite/agent-session-compaction.test.ts +343 -0
  494. package/pi.dev-src/pi-mono/coding-agent/test/suite/agent-session-model-extension.test.ts +325 -0
  495. package/pi.dev-src/pi-mono/coding-agent/test/suite/agent-session-prompt.test.ts +323 -0
  496. package/pi.dev-src/pi-mono/coding-agent/test/suite/agent-session-queue.test.ts +422 -0
  497. package/pi.dev-src/pi-mono/coding-agent/test/suite/agent-session-retry-events.test.ts +358 -0
  498. package/pi.dev-src/pi-mono/coding-agent/test/suite/agent-session-runtime.test.ts +553 -0
  499. package/pi.dev-src/pi-mono/coding-agent/test/suite/harness.ts +208 -0
  500. package/pi.dev-src/pi-mono/coding-agent/test/suite/regressions/2023-queued-slash-command-followup.test.ts +80 -0
  501. package/pi.dev-src/pi-mono/coding-agent/test/suite/regressions/2753-reload-stale-resource-settings.test.ts +100 -0
  502. package/pi.dev-src/pi-mono/coding-agent/test/suite/regressions/2781-skill-collision-precedence.test.ts +120 -0
  503. package/pi.dev-src/pi-mono/coding-agent/test/suite/regressions/2791-fswatch-error-crash.test.ts +106 -0
  504. package/pi.dev-src/pi-mono/coding-agent/test/suite/regressions/2835-tools-allowlist-filters-extension-tools.test.ts +94 -0
  505. package/pi.dev-src/pi-mono/coding-agent/test/suite/regressions/2860-replaced-session-context.test.ts +274 -0
  506. package/pi.dev-src/pi-mono/coding-agent/test/suite/regressions/3217-scoped-model-order.test.ts +104 -0
  507. package/pi.dev-src/pi-mono/coding-agent/test/suite/regressions/3302-find-path-glob.test.ts +72 -0
  508. package/pi.dev-src/pi-mono/coding-agent/test/suite/regressions/3303-find-nested-gitignore.test.ts +83 -0
  509. package/pi.dev-src/pi-mono/coding-agent/test/suite/regressions/3317-network-connection-lost-retry.test.ts +33 -0
  510. package/pi.dev-src/pi-mono/coding-agent/test/system-prompt.test.ts +102 -0
  511. package/pi.dev-src/pi-mono/coding-agent/test/test-harness.test.ts +321 -0
  512. package/pi.dev-src/pi-mono/coding-agent/test/test-harness.ts +446 -0
  513. package/pi.dev-src/pi-mono/coding-agent/test/test-theme-colors.ts +246 -0
  514. package/pi.dev-src/pi-mono/coding-agent/test/theme-export.test.ts +104 -0
  515. package/pi.dev-src/pi-mono/coding-agent/test/tool-execution-component.test.ts +331 -0
  516. package/pi.dev-src/pi-mono/coding-agent/test/tools.test.ts +889 -0
  517. package/pi.dev-src/pi-mono/coding-agent/test/tree-selector.test.ts +655 -0
  518. package/pi.dev-src/pi-mono/coding-agent/test/trigger-compact-extension.test.ts +57 -0
  519. package/pi.dev-src/pi-mono/coding-agent/test/truncate-to-width.test.ts +81 -0
  520. package/pi.dev-src/pi-mono/coding-agent/test/user-message.test.ts +25 -0
  521. package/pi.dev-src/pi-mono/coding-agent/test/utilities.ts +316 -0
  522. package/pi.dev-src/pi-mono/coding-agent/tsconfig.examples.json +16 -0
  523. package/pi.dev-src/pi-mono/coding-agent/vitest.config.ts +26 -0
  524. package/pi.dev-src/pi-mono/mom/dev.sh +30 -0
  525. package/pi.dev-src/pi-mono/mom/docker.sh +95 -0
  526. package/pi.dev-src/pi-mono/mom/scripts/migrate-timestamps.ts +121 -0
  527. package/pi.dev-src/pi-mono/mom/src/agent.ts +882 -0
  528. package/pi.dev-src/pi-mono/mom/src/context.ts +180 -0
  529. package/pi.dev-src/pi-mono/mom/src/download.ts +117 -0
  530. package/pi.dev-src/pi-mono/mom/src/events.ts +442 -0
  531. package/pi.dev-src/pi-mono/mom/src/fs-watch.ts +30 -0
  532. package/pi.dev-src/pi-mono/mom/src/log.ts +271 -0
  533. package/pi.dev-src/pi-mono/mom/src/main.ts +367 -0
  534. package/pi.dev-src/pi-mono/mom/src/sandbox.ts +221 -0
  535. package/pi.dev-src/pi-mono/mom/src/slack.ts +623 -0
  536. package/pi.dev-src/pi-mono/mom/src/store.ts +234 -0
  537. package/pi.dev-src/pi-mono/mom/src/tools/attach.ts +47 -0
  538. package/pi.dev-src/pi-mono/mom/src/tools/bash.ts +97 -0
  539. package/pi.dev-src/pi-mono/mom/src/tools/edit.ts +165 -0
  540. package/pi.dev-src/pi-mono/mom/src/tools/index.ts +19 -0
  541. package/pi.dev-src/pi-mono/mom/src/tools/read.ts +159 -0
  542. package/pi.dev-src/pi-mono/mom/src/tools/truncate.ts +236 -0
  543. package/pi.dev-src/pi-mono/mom/src/tools/write.ts +45 -0
  544. package/pi.dev-src/pi-mono/mom/test/events.test.ts +50 -0
  545. package/pi.dev-src/pi-mono/pods/scripts/model_run.sh +83 -0
  546. package/pi.dev-src/pi-mono/pods/scripts/pod_setup.sh +336 -0
  547. package/pi.dev-src/pi-mono/pods/src/cli.ts +360 -0
  548. package/pi.dev-src/pi-mono/pods/src/commands/models.ts +753 -0
  549. package/pi.dev-src/pi-mono/pods/src/commands/pods.ts +205 -0
  550. package/pi.dev-src/pi-mono/pods/src/commands/prompt.ts +84 -0
  551. package/pi.dev-src/pi-mono/pods/src/config.ts +80 -0
  552. package/pi.dev-src/pi-mono/pods/src/index.ts +2 -0
  553. package/pi.dev-src/pi-mono/pods/src/model-configs.ts +111 -0
  554. package/pi.dev-src/pi-mono/pods/src/models.json +295 -0
  555. package/pi.dev-src/pi-mono/pods/src/ssh.ts +151 -0
  556. package/pi.dev-src/pi-mono/pods/src/types.ts +27 -0
  557. package/pi.dev-src/pi-mono/tui/src/autocomplete.ts +783 -0
  558. package/pi.dev-src/pi-mono/tui/src/components/box.ts +137 -0
  559. package/pi.dev-src/pi-mono/tui/src/components/cancellable-loader.ts +40 -0
  560. package/pi.dev-src/pi-mono/tui/src/components/editor.ts +2280 -0
  561. package/pi.dev-src/pi-mono/tui/src/components/image.ts +104 -0
  562. package/pi.dev-src/pi-mono/tui/src/components/input.ts +503 -0
  563. package/pi.dev-src/pi-mono/tui/src/components/loader.ts +86 -0
  564. package/pi.dev-src/pi-mono/tui/src/components/markdown.ts +852 -0
  565. package/pi.dev-src/pi-mono/tui/src/components/select-list.ts +229 -0
  566. package/pi.dev-src/pi-mono/tui/src/components/settings-list.ts +250 -0
  567. package/pi.dev-src/pi-mono/tui/src/components/spacer.ts +28 -0
  568. package/pi.dev-src/pi-mono/tui/src/components/text.ts +106 -0
  569. package/pi.dev-src/pi-mono/tui/src/components/truncated-text.ts +65 -0
  570. package/pi.dev-src/pi-mono/tui/src/editor-component.ts +74 -0
  571. package/pi.dev-src/pi-mono/tui/src/fuzzy.ts +133 -0
  572. package/pi.dev-src/pi-mono/tui/src/index.ts +106 -0
  573. package/pi.dev-src/pi-mono/tui/src/keybindings.ts +244 -0
  574. package/pi.dev-src/pi-mono/tui/src/keys.ts +1400 -0
  575. package/pi.dev-src/pi-mono/tui/src/kill-ring.ts +46 -0
  576. package/pi.dev-src/pi-mono/tui/src/stdin-buffer.ts +386 -0
  577. package/pi.dev-src/pi-mono/tui/src/terminal-image.ts +414 -0
  578. package/pi.dev-src/pi-mono/tui/src/terminal.ts +373 -0
  579. package/pi.dev-src/pi-mono/tui/src/tui.ts +1243 -0
  580. package/pi.dev-src/pi-mono/tui/src/undo-stack.ts +28 -0
  581. package/pi.dev-src/pi-mono/tui/src/utils.ts +1085 -0
  582. package/pi.dev-src/pi-mono/tui/test/autocomplete.test.ts +542 -0
  583. package/pi.dev-src/pi-mono/tui/test/bug-regression-isimageline-startswith-bug.test.ts +237 -0
  584. package/pi.dev-src/pi-mono/tui/test/chat-simple.ts +129 -0
  585. package/pi.dev-src/pi-mono/tui/test/editor.test.ts +3812 -0
  586. package/pi.dev-src/pi-mono/tui/test/fuzzy.test.ts +98 -0
  587. package/pi.dev-src/pi-mono/tui/test/image-test.ts +56 -0
  588. package/pi.dev-src/pi-mono/tui/test/input.test.ts +580 -0
  589. package/pi.dev-src/pi-mono/tui/test/key-tester.ts +105 -0
  590. package/pi.dev-src/pi-mono/tui/test/keybindings.test.ts +38 -0
  591. package/pi.dev-src/pi-mono/tui/test/keys.test.ts +614 -0
  592. package/pi.dev-src/pi-mono/tui/test/markdown.test.ts +1236 -0
  593. package/pi.dev-src/pi-mono/tui/test/overlay-non-capturing.test.ts +609 -0
  594. package/pi.dev-src/pi-mono/tui/test/overlay-options.test.ts +538 -0
  595. package/pi.dev-src/pi-mono/tui/test/overlay-short-content.test.ts +56 -0
  596. package/pi.dev-src/pi-mono/tui/test/regression-regional-indicator-width.test.ts +52 -0
  597. package/pi.dev-src/pi-mono/tui/test/select-list.test.ts +116 -0
  598. package/pi.dev-src/pi-mono/tui/test/stdin-buffer.test.ts +422 -0
  599. package/pi.dev-src/pi-mono/tui/test/terminal-image.test.ts +279 -0
  600. package/pi.dev-src/pi-mono/tui/test/test-themes.ts +38 -0
  601. package/pi.dev-src/pi-mono/tui/test/truncate-to-width.test.ts +62 -0
  602. package/pi.dev-src/pi-mono/tui/test/truncated-text.test.ts +129 -0
  603. package/pi.dev-src/pi-mono/tui/test/tui-cell-size-input.test.ts +81 -0
  604. package/pi.dev-src/pi-mono/tui/test/tui-overlay-style-leak.test.ts +72 -0
  605. package/pi.dev-src/pi-mono/tui/test/tui-render.test.ts +509 -0
  606. package/pi.dev-src/pi-mono/tui/test/viewport-overwrite-repro.ts +106 -0
  607. package/pi.dev-src/pi-mono/tui/test/virtual-terminal.ts +218 -0
  608. package/pi.dev-src/pi-mono/tui/test/wrap-ansi.test.ts +207 -0
  609. package/pi.dev-src/pi-mono/tui/vitest.config.ts +7 -0
  610. package/pi.dev-src/pi-mono/web-ui/example/index.html +13 -0
  611. package/pi.dev-src/pi-mono/web-ui/example/src/app.css +1 -0
  612. package/pi.dev-src/pi-mono/web-ui/example/src/custom-messages.ts +99 -0
  613. package/pi.dev-src/pi-mono/web-ui/example/src/main.ts +421 -0
  614. package/pi.dev-src/pi-mono/web-ui/example/tsconfig.json +23 -0
  615. package/pi.dev-src/pi-mono/web-ui/example/vite.config.ts +6 -0
  616. package/pi.dev-src/pi-mono/web-ui/scripts/count-prompt-tokens.ts +88 -0
  617. package/pi.dev-src/pi-mono/web-ui/src/ChatPanel.ts +209 -0
  618. package/pi.dev-src/pi-mono/web-ui/src/app.css +68 -0
  619. package/pi.dev-src/pi-mono/web-ui/src/components/AgentInterface.ts +403 -0
  620. package/pi.dev-src/pi-mono/web-ui/src/components/AttachmentTile.ts +107 -0
  621. package/pi.dev-src/pi-mono/web-ui/src/components/ConsoleBlock.ts +72 -0
  622. package/pi.dev-src/pi-mono/web-ui/src/components/CustomProviderCard.ts +100 -0
  623. package/pi.dev-src/pi-mono/web-ui/src/components/ExpandableSection.ts +46 -0
  624. package/pi.dev-src/pi-mono/web-ui/src/components/Input.ts +113 -0
  625. package/pi.dev-src/pi-mono/web-ui/src/components/MessageEditor.ts +405 -0
  626. package/pi.dev-src/pi-mono/web-ui/src/components/MessageList.ts +98 -0
  627. package/pi.dev-src/pi-mono/web-ui/src/components/Messages.ts +383 -0
  628. package/pi.dev-src/pi-mono/web-ui/src/components/ProviderKeyInput.ts +153 -0
  629. package/pi.dev-src/pi-mono/web-ui/src/components/SandboxedIframe.ts +626 -0
  630. package/pi.dev-src/pi-mono/web-ui/src/components/StreamingMessageContainer.ts +103 -0
  631. package/pi.dev-src/pi-mono/web-ui/src/components/ThinkingBlock.ts +43 -0
  632. package/pi.dev-src/pi-mono/web-ui/src/components/message-renderer-registry.ts +28 -0
  633. package/pi.dev-src/pi-mono/web-ui/src/components/sandbox/ArtifactsRuntimeProvider.ts +220 -0
  634. package/pi.dev-src/pi-mono/web-ui/src/components/sandbox/AttachmentsRuntimeProvider.ts +66 -0
  635. package/pi.dev-src/pi-mono/web-ui/src/components/sandbox/ConsoleRuntimeProvider.ts +186 -0
  636. package/pi.dev-src/pi-mono/web-ui/src/components/sandbox/FileDownloadRuntimeProvider.ts +110 -0
  637. package/pi.dev-src/pi-mono/web-ui/src/components/sandbox/RuntimeMessageBridge.ts +82 -0
  638. package/pi.dev-src/pi-mono/web-ui/src/components/sandbox/RuntimeMessageRouter.ts +216 -0
  639. package/pi.dev-src/pi-mono/web-ui/src/components/sandbox/SandboxRuntimeProvider.ts +52 -0
  640. package/pi.dev-src/pi-mono/web-ui/src/dialogs/ApiKeyPromptDialog.ts +75 -0
  641. package/pi.dev-src/pi-mono/web-ui/src/dialogs/AttachmentOverlay.ts +636 -0
  642. package/pi.dev-src/pi-mono/web-ui/src/dialogs/CustomProviderDialog.ts +274 -0
  643. package/pi.dev-src/pi-mono/web-ui/src/dialogs/ModelSelector.ts +370 -0
  644. package/pi.dev-src/pi-mono/web-ui/src/dialogs/PersistentStorageDialog.ts +144 -0
  645. package/pi.dev-src/pi-mono/web-ui/src/dialogs/ProvidersModelsTab.ts +212 -0
  646. package/pi.dev-src/pi-mono/web-ui/src/dialogs/SessionListDialog.ts +150 -0
  647. package/pi.dev-src/pi-mono/web-ui/src/dialogs/SettingsDialog.ts +218 -0
  648. package/pi.dev-src/pi-mono/web-ui/src/index.ts +120 -0
  649. package/pi.dev-src/pi-mono/web-ui/src/prompts/prompts.ts +282 -0
  650. package/pi.dev-src/pi-mono/web-ui/src/storage/app-storage.ts +60 -0
  651. package/pi.dev-src/pi-mono/web-ui/src/storage/backends/indexeddb-storage-backend.ts +193 -0
  652. package/pi.dev-src/pi-mono/web-ui/src/storage/store.ts +33 -0
  653. package/pi.dev-src/pi-mono/web-ui/src/storage/stores/custom-providers-store.ts +62 -0
  654. package/pi.dev-src/pi-mono/web-ui/src/storage/stores/provider-keys-store.ts +33 -0
  655. package/pi.dev-src/pi-mono/web-ui/src/storage/stores/sessions-store.ts +136 -0
  656. package/pi.dev-src/pi-mono/web-ui/src/storage/stores/settings-store.ts +34 -0
  657. package/pi.dev-src/pi-mono/web-ui/src/storage/types.ts +206 -0
  658. package/pi.dev-src/pi-mono/web-ui/src/tools/artifacts/ArtifactElement.ts +14 -0
  659. package/pi.dev-src/pi-mono/web-ui/src/tools/artifacts/ArtifactPill.ts +26 -0
  660. package/pi.dev-src/pi-mono/web-ui/src/tools/artifacts/Console.ts +93 -0
  661. package/pi.dev-src/pi-mono/web-ui/src/tools/artifacts/DocxArtifact.ts +213 -0
  662. package/pi.dev-src/pi-mono/web-ui/src/tools/artifacts/ExcelArtifact.ts +231 -0
  663. package/pi.dev-src/pi-mono/web-ui/src/tools/artifacts/GenericArtifact.ts +117 -0
  664. package/pi.dev-src/pi-mono/web-ui/src/tools/artifacts/HtmlArtifact.ts +195 -0
  665. package/pi.dev-src/pi-mono/web-ui/src/tools/artifacts/ImageArtifact.ts +116 -0
  666. package/pi.dev-src/pi-mono/web-ui/src/tools/artifacts/MarkdownArtifact.ts +82 -0
  667. package/pi.dev-src/pi-mono/web-ui/src/tools/artifacts/PdfArtifact.ts +201 -0
  668. package/pi.dev-src/pi-mono/web-ui/src/tools/artifacts/SvgArtifact.ts +119 -0
  669. package/pi.dev-src/pi-mono/web-ui/src/tools/artifacts/TextArtifact.ts +148 -0
  670. package/pi.dev-src/pi-mono/web-ui/src/tools/artifacts/artifacts-tool-renderer.ts +310 -0
  671. package/pi.dev-src/pi-mono/web-ui/src/tools/artifacts/artifacts.ts +713 -0
  672. package/pi.dev-src/pi-mono/web-ui/src/tools/artifacts/index.ts +7 -0
  673. package/pi.dev-src/pi-mono/web-ui/src/tools/extract-document.ts +275 -0
  674. package/pi.dev-src/pi-mono/web-ui/src/tools/index.ts +46 -0
  675. package/pi.dev-src/pi-mono/web-ui/src/tools/javascript-repl.ts +293 -0
  676. package/pi.dev-src/pi-mono/web-ui/src/tools/renderer-registry.ts +130 -0
  677. package/pi.dev-src/pi-mono/web-ui/src/tools/renderers/BashRenderer.ts +52 -0
  678. package/pi.dev-src/pi-mono/web-ui/src/tools/renderers/CalculateRenderer.ts +58 -0
  679. package/pi.dev-src/pi-mono/web-ui/src/tools/renderers/DefaultRenderer.ts +103 -0
  680. package/pi.dev-src/pi-mono/web-ui/src/tools/renderers/GetCurrentTimeRenderer.ts +92 -0
  681. package/pi.dev-src/pi-mono/web-ui/src/tools/types.ts +15 -0
  682. package/pi.dev-src/pi-mono/web-ui/src/utils/attachment-utils.ts +472 -0
  683. package/pi.dev-src/pi-mono/web-ui/src/utils/auth-token.ts +22 -0
  684. package/pi.dev-src/pi-mono/web-ui/src/utils/format.ts +42 -0
  685. package/pi.dev-src/pi-mono/web-ui/src/utils/i18n.ts +653 -0
  686. package/pi.dev-src/pi-mono/web-ui/src/utils/model-discovery.ts +277 -0
  687. package/pi.dev-src/pi-mono/web-ui/src/utils/proxy-utils.ts +139 -0
  688. package/pi.dev-src/pi-mono/web-ui/src/utils/test-sessions.ts +2357 -0
  689. package/pi.dev-src/pi-mono/web-ui/tsconfig.json +7 -0
  690. package/scripts/lib/extension-debug-harness.ts +3 -10
  691. package/scripts/lib/recording-extension-api.ts +71 -2
  692. package/scripts/pi-usereq-debug.sh +1 -1
  693. package/scripts/tool-args-to-params.ts +4 -15
  694. package/src/cli.ts +24 -68
  695. package/src/core/agent-tool-json.ts +53 -239
  696. package/src/core/compress-payload.ts +4 -74
  697. package/src/core/compress.ts +26 -15
  698. package/src/core/config.ts +468 -42
  699. package/src/core/debug-runtime.ts +542 -0
  700. package/src/core/extension-status.ts +296 -46
  701. package/src/core/find-constructs.ts +3 -3
  702. package/src/core/find-payload.ts +26 -132
  703. package/src/core/path-context.ts +346 -81
  704. package/src/core/pi-notify.ts +115 -5
  705. package/src/core/pi-usereq-tools.ts +57 -19
  706. package/src/core/prompt-command-catalog.ts +46 -0
  707. package/src/core/prompt-command-runtime.ts +1650 -0
  708. package/src/core/prompt-command-state.ts +211 -0
  709. package/src/core/prompts.ts +173 -41
  710. package/src/core/reference-payload.ts +5 -63
  711. package/src/core/resources.ts +61 -5
  712. package/src/core/settings-menu.ts +12 -6
  713. package/src/core/source-analyzer.ts +27 -7
  714. package/src/core/token-counter.ts +6 -76
  715. package/src/core/tool-runner.ts +38 -317
  716. package/src/index.ts +2062 -950
  717. package/src/resources/instructions/git_commit.md +16 -0
  718. package/src/resources/instructions/git_read-only.md +2 -0
  719. package/src/resources/prompts/analyze.md +12 -13
  720. package/src/resources/prompts/change.md +21 -59
  721. package/src/resources/prompts/check.md +14 -16
  722. package/src/resources/prompts/cover.md +21 -59
  723. package/src/resources/prompts/create.md +5 -4
  724. package/src/resources/prompts/fix.md +20 -58
  725. package/src/resources/prompts/flowchart.md +14 -49
  726. package/src/resources/prompts/implement.md +14 -50
  727. package/src/resources/prompts/new.md +20 -58
  728. package/src/resources/prompts/readme.md +14 -50
  729. package/src/resources/prompts/recreate.md +14 -45
  730. package/src/resources/prompts/refactor.md +20 -58
  731. package/src/resources/prompts/references.md +7 -43
  732. package/src/resources/prompts/renumber.md +8 -39
  733. package/src/resources/prompts/workflow.md +14 -50
  734. package/src/resources/prompts/write.md +5 -4
  735. package/tests/attended-results-scenarios.ts +30 -136
  736. package/tests/cli-command-option-parity.test.ts +148 -192
  737. package/tests/debug-extension-harness.test.ts +69 -68
  738. package/tests/extension-registration.test.ts +3118 -657
  739. package/tests/fixtures_attended_results/project/compress-line-numbers.json +1 -1
  740. package/tests/fixtures_attended_results/project/compress.json +1 -1
  741. package/tests/fixtures_attended_results/project/find-line-numbers.json +1 -1
  742. package/tests/fixtures_attended_results/project/find.json +1 -1
  743. package/tests/fixtures_attended_results/project/references.json +2 -2
  744. package/tests/fixtures_attended_results/standalone/files-compress/fixture_go.go.json +1 -1
  745. package/tests/fixtures_attended_results/standalone/files-compress-line-numbers/fixture_go.go.json +1 -1
  746. package/tests/fixtures_attended_results/standalone/files-find/fixture_go.go.json +1 -1
  747. package/tests/fixtures_attended_results/standalone/files-find-line-numbers/fixture_go.go.json +1 -1
  748. package/tests/fixtures_attended_results/standalone/files-references/fixture_c.c.json +1 -1
  749. package/tests/fixtures_attended_results/standalone/files-references/fixture_cpp.cpp.json +1 -1
  750. package/tests/fixtures_attended_results/standalone/files-references/fixture_csharp.cs.json +1 -1
  751. package/tests/fixtures_attended_results/standalone/files-references/fixture_elixir.ex.json +1 -1
  752. package/tests/fixtures_attended_results/standalone/files-references/fixture_go.go.json +1 -1
  753. package/tests/fixtures_attended_results/standalone/files-references/fixture_haskell.hs.json +1 -1
  754. package/tests/fixtures_attended_results/standalone/files-references/fixture_java.java.json +1 -1
  755. package/tests/fixtures_attended_results/standalone/files-references/fixture_javascript.js.json +1 -1
  756. package/tests/fixtures_attended_results/standalone/files-references/fixture_kotlin.kt.json +1 -1
  757. package/tests/fixtures_attended_results/standalone/files-references/fixture_lua.lua.json +1 -1
  758. package/tests/fixtures_attended_results/standalone/files-references/fixture_perl.pl.json +1 -1
  759. package/tests/fixtures_attended_results/standalone/files-references/fixture_php.php.json +1 -1
  760. package/tests/fixtures_attended_results/standalone/files-references/fixture_python.py.json +1 -1
  761. package/tests/fixtures_attended_results/standalone/files-references/fixture_ruby.rb.json +1 -1
  762. package/tests/fixtures_attended_results/standalone/files-references/fixture_rust.rs.json +1 -1
  763. package/tests/fixtures_attended_results/standalone/files-references/fixture_scala.scala.json +1 -1
  764. package/tests/fixtures_attended_results/standalone/files-references/fixture_shell.sh.json +1 -1
  765. package/tests/fixtures_attended_results/standalone/files-references/fixture_swift.swift.json +1 -1
  766. package/tests/fixtures_attended_results/standalone/files-references/fixture_typescript.ts.json +1 -1
  767. package/tests/fixtures_attended_results/standalone/files-references/fixture_zig.zig.json +1 -1
  768. package/tests/helpers.ts +24 -15
  769. package/tests/oracle-project.test.ts +29 -30
  770. package/tests/oracle-standalone.test.ts +27 -1
  771. package/tests/prompt-rendering.test.ts +40 -9
  772. package/tests/python_oracle_stubs/tiktoken.py +76 -0
@@ -6,10 +6,19 @@ import os from "node:os";
6
6
  import path from "node:path";
7
7
  import piUsereqExtension from "../src/index.ts";
8
8
  import {
9
+ createStaticCheckLanguageConfig,
9
10
  DEFAULT_DOCS_DIR,
10
11
  getDefaultConfig,
11
12
  getProjectConfigPath,
13
+ loadConfig,
12
14
  } from "../src/core/config.js";
15
+ import {
16
+ DEBUG_PROMPT_NAMES,
17
+ DEFAULT_DEBUG_LOG_FILE,
18
+ DEFAULT_DEBUG_LOG_ON_STATUS,
19
+ DEFAULT_DEBUG_WORKFLOW_EVENTS,
20
+ } from "../src/core/debug-runtime.js";
21
+ import { PROMPT_COMMAND_NAMES } from "../src/core/prompt-command-catalog.js";
13
22
  import {
14
23
  setJsTiktokenModuleLoaderForTests,
15
24
  } from "../src/core/token-counter.js";
@@ -18,10 +27,27 @@ import {
18
27
  setPiNotifySpawnForTests,
19
28
  } from "../src/core/pi-notify.js";
20
29
  import {
30
+ abortPromptCommandExecution,
31
+ activatePromptCommandExecution,
32
+ preparePromptCommandExecution,
33
+ restorePromptCommandExecution,
34
+ setPromptCommandPostCreateHookForTests,
35
+ } from "../src/core/prompt-command-runtime.js";
36
+ import {
37
+ readPersistedPromptCommandSessionContext,
38
+ } from "../src/core/prompt-command-state.js";
39
+ import {
40
+ comparePiUsereqStartupToolNames,
41
+ PI_USEREQ_CUSTOM_TOOL_NAMES,
21
42
  PI_USEREQ_DEFAULT_ENABLED_TOOL_NAMES,
22
43
  PI_USEREQ_EMBEDDED_TOOL_NAMES,
23
44
  PI_USEREQ_STARTUP_TOOL_NAMES,
24
45
  } from "../src/core/pi-usereq-tools.js";
46
+ import {
47
+ createPiUsereqStatusController,
48
+ setPiUsereqStatusConfig,
49
+ setPiUsereqWorkflowState,
50
+ } from "../src/core/extension-status.js";
25
51
  import { getSupportedStaticCheckLanguageSupport } from "../src/core/static-check.js";
26
52
  import { createTempDir, initFixtureRepo } from "./helpers.js";
27
53
 
@@ -44,6 +70,7 @@ type RegisteredTool = {
44
70
  parameters?: unknown;
45
71
  sourceInfo?: Record<string, unknown>;
46
72
  execute?: (toolCallId: string, params: any, signal?: AbortSignal, onUpdate?: unknown, ctx?: any) => Promise<any> | any;
73
+ renderResult?: (result: any, options: { expanded?: boolean; isPartial?: boolean }, theme: FakeThemeAdapter, context: { args?: Record<string, unknown>; lastComponent?: unknown }) => { render: (width: number) => string[] };
47
74
  };
48
75
 
49
76
  /**
@@ -54,6 +81,39 @@ type RegisteredTool = {
54
81
  */
55
82
  type RegisteredShortcut = { description?: string; handler: (ctx: any) => Promise<void> | void };
56
83
 
84
+ /**
85
+ * @brief Applies targeted `.pi-usereq.json` overrides inside one test project.
86
+ * @details Loads the persisted config JSON, merges the supplied top-level overrides, and writes the updated payload back with a trailing newline so prompt-command tests can script worktree and notification behavior deterministically. Runtime is O(n) in config size. Side effects include filesystem reads and file overwrite.
87
+ * @param[in] projectBase {string} Fixture project root.
88
+ * @param[in] overrides {Record<string, unknown>} Top-level config overrides.
89
+ * @return {void} No return value.
90
+ */
91
+ function writeProjectConfigOverrides(projectBase: string, overrides: Record<string, unknown>): void {
92
+ const configPath = getProjectConfigPath(projectBase);
93
+ const config = JSON.parse(fs.readFileSync(configPath, "utf8")) as Record<string, unknown>;
94
+ Object.assign(config, overrides);
95
+ fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8");
96
+ if (fs.existsSync(path.join(projectBase, ".git"))) {
97
+ const gitEnv = {
98
+ ...process.env,
99
+ GIT_AUTHOR_NAME: "pi-usereq",
100
+ GIT_AUTHOR_EMAIL: "pi-usereq@example.com",
101
+ GIT_COMMITTER_NAME: "pi-usereq",
102
+ GIT_COMMITTER_EMAIL: "pi-usereq@example.com",
103
+ };
104
+ assert.equal(spawnSync("git", ["add", ".pi-usereq.json", ".req/config.json"], {
105
+ cwd: projectBase,
106
+ encoding: "utf8",
107
+ }).status, 0);
108
+ const commit = spawnSync("git", ["commit", "-m", "config override"], {
109
+ cwd: projectBase,
110
+ encoding: "utf8",
111
+ env: gitEnv,
112
+ });
113
+ assert.equal(commit.status, 0, commit.stderr);
114
+ }
115
+ }
116
+
57
117
  /**
58
118
  * @brief Represents the fake pi runtime returned by `createFakePi`.
59
119
  * @details Captures registered commands, tools, shortcuts, event handlers, active-tool state, and prompt-delivery payloads for deterministic extension-registration assertions. The alias is compile-time only and introduces no runtime side effects.
@@ -83,6 +143,9 @@ interface FakeSessionManager {
83
143
  appendMessage: (message: unknown) => string;
84
144
  getBranch: () => FakeSessionEntry[];
85
145
  getEntries: () => FakeSessionEntry[];
146
+ getCwd: () => string;
147
+ getSessionDir: () => string | undefined;
148
+ getSessionFile: () => string | undefined;
86
149
  }
87
150
 
88
151
  /**
@@ -154,9 +217,48 @@ function extractSessionMessageText(message: unknown): string {
154
217
  * @details Stores appended custom entries and setup messages in deterministic insertion order, returns stable synthetic entry identifiers, and exposes branch reads used by `session_start` handlers. Runtime is O(1) per mutation plus O(n) per snapshot copy. Side effects are limited to in-memory state mutation.
155
218
  * @return {FakeSessionManager} Fake session manager.
156
219
  */
157
- function createFakeSessionManager(): FakeSessionManager {
220
+ function createFakeSessionFile(cwd: string): string {
221
+ const sessionDir = path.join(os.tmpdir(), "pi-usereq-test-sessions");
222
+ fs.mkdirSync(sessionDir, { recursive: true });
223
+ const sessionFile = path.join(sessionDir, `session-${Date.now()}-${Math.random().toString(16).slice(2)}.jsonl`);
224
+ fs.writeFileSync(sessionFile, `${JSON.stringify({
225
+ type: "session",
226
+ version: 3,
227
+ id: path.basename(sessionFile, ".jsonl"),
228
+ timestamp: new Date(0).toISOString(),
229
+ cwd,
230
+ })}\n`, "utf8");
231
+ return sessionFile;
232
+ }
233
+
234
+ /**
235
+ * @brief Reads the stored cwd header from one fake or persisted session file.
236
+ * @details Parses the first JSONL line and returns its `cwd` field when available so fake `ctx.switchSession(...)` calls can mirror pi session-path semantics without a live runtime. Runtime is O(n) in header size. Side effects are limited to filesystem reads.
237
+ * @param[in] sessionFile {string} Session file path.
238
+ * @param[in] fallbackCwd {string} Fallback cwd when the file cannot be parsed.
239
+ * @return {string} Parsed session cwd or the fallback value.
240
+ */
241
+ function readFakeSessionFileCwd(sessionFile: string, fallbackCwd: string): string {
242
+ try {
243
+ const headerLine = fs.readFileSync(sessionFile, "utf8").split(/\r?\n/)[0] ?? "";
244
+ const header = JSON.parse(headerLine) as { cwd?: unknown };
245
+ return typeof header.cwd === "string" && header.cwd !== "" ? header.cwd : fallbackCwd;
246
+ } catch {
247
+ return fallbackCwd;
248
+ }
249
+ }
250
+
251
+ /**
252
+ * @brief Creates a fake session manager for session-aware tests.
253
+ * @details Stores appended custom entries and setup messages in deterministic insertion order, returns stable synthetic entry identifiers, and exposes persisted session-file metadata used by prompt-command session switching. Runtime is O(1) per mutation plus O(n) per snapshot copy. Side effects include in-memory state mutation and one fake session-file write.
254
+ * @param[in] cwd {string} Initial session cwd.
255
+ * @return {FakeSessionManager} Fake session manager.
256
+ */
257
+ function createFakeSessionManager(cwd: string): FakeSessionManager {
158
258
  const entries: FakeSessionEntry[] = [];
159
259
  let nextEntryId = 0;
260
+ let currentCwd = cwd;
261
+ const sessionFile = createFakeSessionFile(cwd);
160
262
  const buildEntryId = (): string => `entry-${nextEntryId++}`;
161
263
  return {
162
264
  appendCustomEntry(customType: string, data?: unknown): string {
@@ -175,6 +277,16 @@ function createFakeSessionManager(): FakeSessionManager {
175
277
  getEntries(): FakeSessionEntry[] {
176
278
  return entries.map((entry) => ({ ...entry }));
177
279
  },
280
+ getCwd(): string {
281
+ return currentCwd;
282
+ },
283
+ getSessionDir(): string {
284
+ return path.dirname(sessionFile);
285
+ },
286
+ getSessionFile(): string {
287
+ currentCwd = readFakeSessionFileCwd(sessionFile, currentCwd);
288
+ return sessionFile;
289
+ },
178
290
  };
179
291
  }
180
292
 
@@ -296,9 +408,7 @@ function formatFakeThemeBackgroundFromForeground(color: string, text: string): s
296
408
 
297
409
  /**
298
410
  * @brief Builds the expected fake context-gauge payload for assertions.
299
- * @details Resolves the documented icon thresholds for `0-100%` and emits the
300
- * blinking red overflow icon for percent values above `100`. Runtime is O(1).
301
- * No external state is mutated.
411
+ * @details Resolves the documented icon thresholds for `0-90%`, emits the blinking warning full icon for percent values above `90` through `100`, and emits the blinking red overflow icon for percent values above `100`. Runtime is O(1). No external state is mutated.
302
412
  * @param[in] options {{ filledCells: number; percent?: number | null }} Expected context-gauge facts.
303
413
  * @return {string} Encoded context-gauge string.
304
414
  */
@@ -322,19 +432,17 @@ function buildExpectedFakeContextBar(options: {
322
432
  if (percent <= 90) {
323
433
  return formatFakeThemeForeground("warning", "▕▆▏");
324
434
  }
325
- return formatFakeThemeForeground("warning", "▕█▏");
435
+ return formatFakeThemeForeground("warning", "\u001b[5m▕█▏\u001b[25m");
326
436
  }
327
437
 
328
438
  /**
329
439
  * @brief Builds the expected fake pi-usereq status-bar string for assertions.
330
- * @details Reconstructs the field order, separators, icon-based context gauge,
331
- * consolidated elapsed field, and sound field emitted by the extension using
332
- * deterministic fake theme markers. Runtime is O(1). No external state is
333
- * mutated.
334
- * @param[in] options {{ basePath: string; contextFilledCells: number; contextPercent?: number | null; et: string; sound?: string }} Expected status facts.
440
+ * @details Reconstructs the field order, workflow-state highlighting, separators, icon-based context gauge, consolidated elapsed field, and sound field emitted by the extension using deterministic fake theme markers. Runtime is O(1). No external state is mutated.
441
+ * @param[in] options {{ workflowState?: string; basePath: string; contextFilledCells: number; contextPercent?: number | null; et: string; sound?: string }} Expected status facts where `basePath` carries the rendered `current-path` value.
335
442
  * @return {string} Encoded status-bar string.
336
443
  */
337
444
  function buildExpectedFakeStatusText(options: {
445
+ workflowState?: string;
338
446
  basePath: string;
339
447
  docsDir?: string;
340
448
  testsDir?: string;
@@ -346,12 +454,17 @@ function buildExpectedFakeStatusText(options: {
346
454
  }): string {
347
455
  const buildField = (fieldName: string, value: string): string =>
348
456
  `${formatFakeThemeForeground("accent", `${fieldName}:`)}${formatFakeThemeForeground("warning", value)}`;
457
+ const workflowStateText = options.workflowState ?? "idle";
458
+ const workflowStateValue = workflowStateText === "error"
459
+ ? formatFakeThemeForeground("error", "\u001b[5merror\u001b[25m")
460
+ : formatFakeThemeForeground("warning", workflowStateText);
349
461
  const contextBar = buildExpectedFakeContextBar({
350
462
  filledCells: options.contextFilledCells,
351
463
  percent: options.contextPercent,
352
464
  });
353
465
  return [
354
- buildField("base", options.basePath),
466
+ `${formatFakeThemeForeground("accent", "status:")}${workflowStateValue}`,
467
+ buildField("current-path", options.basePath),
355
468
  `${formatFakeThemeForeground("accent", "context:")}${contextBar}`,
356
469
  buildField("elapsed", options.et),
357
470
  buildField("sound", options.sound ?? "none"),
@@ -396,8 +509,8 @@ function buildExpectedNotifyBasePath(cwd: string): string {
396
509
  * @return {string} `~`-relative or absolute config-path display string.
397
510
  */
398
511
  function buildExpectedShowConfigPath(cwd: string): string {
399
- return buildExpectedNotifyBasePath(path.dirname(path.dirname(getProjectConfigPath(cwd)))) === buildExpectedNotifyBasePath(cwd)
400
- ? `${buildExpectedNotifyBasePath(cwd)}/.pi-usereq/config.json`
512
+ return buildExpectedNotifyBasePath(path.dirname(getProjectConfigPath(cwd))) === buildExpectedNotifyBasePath(cwd)
513
+ ? `${buildExpectedNotifyBasePath(cwd)}/.pi-usereq.json`
401
514
  : getProjectConfigPath(cwd).split(path.sep).join("/");
402
515
  }
403
516
 
@@ -430,7 +543,13 @@ function createFakeCtx(cwd: string, plan: FakeCtxPlan = { selects: [] }, options
430
543
  const selects = [...plan.selects];
431
544
  const inputs = [...(plan.inputs ?? [])];
432
545
  const getContextUsage = options.getContextUsage ?? (() => undefined);
433
- const sessionManager = options.sessionManager ?? createFakeSessionManager();
546
+ const baseSessionManager = options.sessionManager ?? createFakeSessionManager(cwd);
547
+ let currentSessionCwd = typeof baseSessionManager.getCwd === "function"
548
+ ? baseSessionManager.getCwd()
549
+ : cwd;
550
+ let currentSessionFile = typeof baseSessionManager.getSessionFile === "function"
551
+ ? baseSessionManager.getSessionFile()
552
+ : createFakeSessionFile(currentSessionCwd);
434
553
  const theme = options.theme ?? {
435
554
  fg: (color: string, text: string) => formatFakeThemeForeground(color, text),
436
555
  bgFromFg: (color: string, text: string) => formatFakeThemeBackgroundFromForeground(color, text),
@@ -445,13 +564,43 @@ function createFakeCtx(cwd: string, plan: FakeCtxPlan = { selects: [] }, options
445
564
  waitForIdleCalls: 0,
446
565
  newSessions: [] as Array<{ messages: string[] }>,
447
566
  };
448
- return {
567
+ const sessionManager: FakeSessionManager = {
568
+ appendCustomEntry(customType: string, data?: unknown): string {
569
+ return baseSessionManager.appendCustomEntry(customType, data);
570
+ },
571
+ appendMessage(message: unknown): string {
572
+ return baseSessionManager.appendMessage(message);
573
+ },
574
+ getBranch(): FakeSessionEntry[] {
575
+ return baseSessionManager.getBranch();
576
+ },
577
+ getEntries(): FakeSessionEntry[] {
578
+ return baseSessionManager.getEntries();
579
+ },
580
+ getCwd(): string {
581
+ return currentSessionCwd;
582
+ },
583
+ getSessionDir(): string | undefined {
584
+ return typeof currentSessionFile === "string" ? path.dirname(currentSessionFile) : undefined;
585
+ },
586
+ getSessionFile(): string | undefined {
587
+ return currentSessionFile;
588
+ },
589
+ };
590
+ const fakeCtx: any = {
449
591
  cwd,
450
592
  __state: state,
451
593
  sessionManager,
452
594
  async waitForIdle() {
453
595
  state.waitForIdleCalls += 1;
454
596
  },
597
+ async switchSession(sessionPath: string) {
598
+ currentSessionFile = sessionPath;
599
+ currentSessionCwd = readFakeSessionFileCwd(sessionPath, currentSessionCwd);
600
+ fakeCtx.cwd = currentSessionCwd;
601
+ process.chdir(currentSessionCwd);
602
+ return { cancelled: false };
603
+ },
455
604
  async newSession(newSessionOptions?: {
456
605
  parentSession?: string;
457
606
  setup?: (sessionManager: FakeSessionManager) => Promise<void>;
@@ -473,6 +622,15 @@ function createFakeCtx(cwd: string, plan: FakeCtxPlan = { selects: [] }, options
473
622
  getEntries(): FakeSessionEntry[] {
474
623
  return sessionManager.getEntries();
475
624
  },
625
+ getCwd(): string {
626
+ return sessionManager.getCwd();
627
+ },
628
+ getSessionDir(): string | undefined {
629
+ return sessionManager.getSessionDir();
630
+ },
631
+ getSessionFile(): string | undefined {
632
+ return sessionManager.getSessionFile();
633
+ },
476
634
  };
477
635
  if (newSessionOptions?.setup) {
478
636
  await newSessionOptions.setup(setupSessionManager);
@@ -529,6 +687,8 @@ function createFakeCtx(cwd: string, plan: FakeCtxPlan = { selects: [] }, options
529
687
  });
530
688
  if (response === undefined) {
531
689
  bridge.cancel();
690
+ } else if (response === "Save and close" && !bridge.choices.some((choice) => choice.label === response)) {
691
+ bridge.cancel();
532
692
  } else {
533
693
  assert.ok(bridge.selectByLabel(response), `unsupported custom selection ${response}`);
534
694
  }
@@ -549,6 +709,7 @@ function createFakeCtx(cwd: string, plan: FakeCtxPlan = { selects: [] }, options
549
709
  },
550
710
  },
551
711
  };
712
+ return fakeCtx;
552
713
  }
553
714
 
554
715
  /**
@@ -604,25 +765,19 @@ test("extension registers prompt and config commands while exposing tool capabil
604
765
  ]) {
605
766
  assert.ok(commandNames.includes(name), `missing command ${name}`);
606
767
  }
768
+ assert.equal(pi.commands.get("req-analyze")?.description, "Produce an analysis report");
607
769
 
608
770
  for (const name of [
609
- "git-path",
610
- "get-base-path",
611
771
  "files-tokens",
612
772
  "files-references",
613
773
  "files-compress",
614
- "files-find",
774
+ "files-search",
615
775
  "references",
616
776
  "compress",
617
- "find",
777
+ "search",
618
778
  "tokens",
619
779
  "files-static-check",
620
780
  "static-check",
621
- "git-check",
622
- "docs-check",
623
- "git-wt-name",
624
- "git-wt-create",
625
- "git-wt-delete",
626
781
  "pi-usereq-show-config",
627
782
  "test-static-check",
628
783
  ]) {
@@ -631,23 +786,16 @@ test("extension registers prompt and config commands while exposing tool capabil
631
786
 
632
787
  const toolNames = pi.tools.map((tool: RegisteredTool) => tool.name).sort();
633
788
  for (const name of [
634
- "git-path",
635
- "get-base-path",
636
789
  "files-tokens",
637
790
  "files-references",
638
791
  "files-compress",
639
- "files-find",
792
+ "files-search",
640
793
  "references",
641
794
  "compress",
642
- "find",
795
+ "search",
643
796
  "tokens",
644
797
  "files-static-check",
645
798
  "static-check",
646
- "git-check",
647
- "docs-check",
648
- "git-wt-name",
649
- "git-wt-create",
650
- "git-wt-delete",
651
799
  ]) {
652
800
  assert.ok(toolNames.includes(name), `missing tool ${name}`);
653
801
  }
@@ -662,13 +810,13 @@ test("token tools register agent-oriented descriptions and schema details", () =
662
810
  assert.ok(filesTokens, "missing files-tokens tool");
663
811
  assert.ok(tokens, "missing tokens tool");
664
812
 
665
- assert.match(filesTokens.description ?? "", /token-optimized JSON payload/);
666
- assert.ok(filesTokens.promptGuidelines?.some((line) => line.includes("Output contract:")));
667
- assert.match(String((filesTokens.parameters as { description?: string } | undefined)?.description ?? ""), /token metrics/);
813
+ assert.match(filesTokens.description ?? "", /monolithic token pack summary/);
814
+ assert.ok(filesTokens.promptGuidelines?.some((line) => line.includes("monolithic pack-summary text")));
815
+ assert.match(String((filesTokens.parameters as { description?: string } | undefined)?.description ?? ""), /monolithic pack-summary text/);
668
816
 
669
817
  assert.match(tokens.description ?? "", /canonical docs/);
670
- assert.ok(tokens.promptGuidelines?.some((line) => line.includes("registration metadata")));
671
- assert.match(String((tokens.parameters as { description?: string } | undefined)?.description ?? ""), /token-optimized JSON shape/);
818
+ assert.ok(tokens.promptGuidelines?.some((line) => line.includes("monolithic pack-summary text")));
819
+ assert.match(String((tokens.parameters as { description?: string } | undefined)?.description ?? ""), /monolithic pack-summary text/);
672
820
  });
673
821
 
674
822
  test("reference tools register agent-oriented descriptions and schema details", () => {
@@ -680,36 +828,36 @@ test("reference tools register agent-oriented descriptions and schema details",
680
828
  assert.ok(filesReferences, "missing files-references tool");
681
829
  assert.ok(references, "missing references tool");
682
830
 
683
- assert.match(filesReferences.description ?? "", /summary, repository, files, and execution sections/);
684
- assert.ok(filesReferences.promptGuidelines?.some((line) => line.includes("Numeric contract:")));
685
- assert.ok(filesReferences.promptGuidelines?.some((line) => line.includes("Behavior contract:")));
686
- assert.match(String((filesReferences.parameters as { description?: string } | undefined)?.description ?? ""), /structured Doxygen fields/);
831
+ assert.match(filesReferences.description ?? "", /monolithic references markdown report/);
832
+ assert.ok(filesReferences.promptGuidelines?.some((line) => line.includes("monolithic markdown")));
833
+ assert.ok(filesReferences.promptGuidelines?.some((line) => line.includes("Formatting contract:")));
834
+ assert.match(String((filesReferences.parameters as { description?: string } | undefined)?.description ?? ""), /monolithic markdown/);
687
835
 
688
836
  assert.match(references.description ?? "", /configured project source directories/);
689
- assert.ok(references.promptGuidelines?.some((line) => line.includes("source_directory_paths")));
837
+ assert.ok(references.promptGuidelines?.some((line) => line.includes("file-structure markdown block")));
690
838
  assert.ok(references.promptGuidelines?.some((line) => line.includes("Configuration contract:")));
691
- assert.match(String((references.parameters as { description?: string } | undefined)?.description ?? ""), /directory tree/);
839
+ assert.match(String((references.parameters as { description?: string } | undefined)?.description ?? ""), /monolithic markdown/);
692
840
  });
693
841
 
694
- test("find tools register agent-oriented descriptions and schema details", () => {
842
+ test("search tools register agent-oriented descriptions and schema details", () => {
695
843
  const pi = createFakePi();
696
844
  piUsereqExtension(pi);
697
845
 
698
- const filesFind = pi.tools.find((tool: RegisteredTool) => tool.name === "files-find");
699
- const find = pi.tools.find((tool: RegisteredTool) => tool.name === "find");
700
- assert.ok(filesFind, "missing files-find tool");
701
- assert.ok(find, "missing find tool");
702
-
703
- assert.match(filesFind.description ?? "", /token-optimized JSON payload/);
704
- assert.ok(filesFind.promptGuidelines?.some((line) => line.includes("Regex rule:")));
705
- assert.ok(filesFind.promptGuidelines?.some((line) => line.includes("Supported tags [Typescript]:")));
706
- assert.ok(filesFind.promptGuidelines?.some((line) => line.includes("Supported tags [Python]:")));
707
- assert.match(String((filesFind.parameters as { description?: string } | undefined)?.description ?? ""), /Static supported-tag matrices are documented in tool registration metadata/);
708
-
709
- assert.match(find.description ?? "", /configured project source directories/);
710
- assert.ok(find.promptGuidelines?.some((line) => line.includes("source_directory_paths")));
711
- assert.ok(find.promptGuidelines?.some((line) => line.includes("Tag rule:")));
712
- assert.match(String((find.parameters as { description?: string } | undefined)?.description ?? ""), /Regex matches construct names only/);
846
+ const filesSearch = pi.tools.find((tool: RegisteredTool) => tool.name === "files-search");
847
+ const search = pi.tools.find((tool: RegisteredTool) => tool.name === "search");
848
+ assert.ok(filesSearch, "missing files-search tool");
849
+ assert.ok(search, "missing search tool");
850
+
851
+ assert.match(filesSearch.description ?? "", /monolithic construct-search markdown report/);
852
+ assert.ok(filesSearch.promptGuidelines?.some((line) => line.includes("Regex rule:")));
853
+ assert.ok(filesSearch.promptGuidelines?.some((line) => line.includes("Supported tags [Typescript]:")));
854
+ assert.ok(filesSearch.promptGuidelines?.some((line) => line.includes("Supported tags [Python]:")));
855
+ assert.match(String((filesSearch.parameters as { description?: string } | undefined)?.description ?? ""), /monolithic markdown/);
856
+
857
+ assert.match(search.description ?? "", /configured project source directories/);
858
+ assert.ok(search.promptGuidelines?.some((line) => line.includes("monolithic markdown")));
859
+ assert.ok(search.promptGuidelines?.some((line) => line.includes("Tag rule:")));
860
+ assert.match(String((search.parameters as { description?: string } | undefined)?.description ?? ""), /monolithic markdown/);
713
861
  });
714
862
 
715
863
  test("compression tools register agent-oriented descriptions and schema details", () => {
@@ -721,18 +869,69 @@ test("compression tools register agent-oriented descriptions and schema details"
721
869
  assert.ok(filesCompress, "missing files-compress tool");
722
870
  assert.ok(compress, "missing compress tool");
723
871
 
724
- assert.match(filesCompress.description ?? "", /summary, repository, files, and execution sections/);
872
+ assert.match(filesCompress.description ?? "", /monolithic compression markdown report/);
725
873
  assert.ok(filesCompress.promptGuidelines?.some((line) => line.includes("Line-number behavior:")));
726
- assert.ok(filesCompress.promptGuidelines?.some((line) => line.includes("Behavior contract:")));
727
- assert.match(String((filesCompress.parameters as { description?: string } | undefined)?.description ?? ""), /structured compressed lines/);
874
+ assert.ok(filesCompress.promptGuidelines?.some((line) => line.includes("Formatting contract:")));
875
+ assert.match(String((filesCompress.parameters as { description?: string } | undefined)?.description ?? ""), /monolithic markdown/);
728
876
 
729
877
  assert.match(compress.description ?? "", /configured project source directories/);
730
- assert.ok(compress.promptGuidelines?.some((line) => line.includes("Configuration contract:")));
731
- assert.ok(compress.promptGuidelines?.some((line) => line.includes("Behavior contract:")));
732
- assert.match(String((compress.parameters as { description?: string } | undefined)?.description ?? ""), /structured compressed lines/);
878
+ assert.ok(compress.promptGuidelines?.some((line) => line.includes("monolithic markdown")));
879
+ assert.ok(compress.promptGuidelines?.some((line) => line.includes("Formatting contract:")));
880
+ assert.match(String((compress.parameters as { description?: string } | undefined)?.description ?? ""), /monolithic markdown/);
881
+ });
882
+
883
+ test("agent tools define custom renderResult with compact and expanded structured views", async () => {
884
+ const { projectBase } = initFixtureRepo({ fixtures: [] });
885
+ try {
886
+ const pi = createFakePi();
887
+ piUsereqExtension(pi);
888
+ const toolNames = [
889
+ "files-tokens",
890
+ "files-references",
891
+ "files-compress",
892
+ "files-search",
893
+ "references",
894
+ "compress",
895
+ "search",
896
+ "tokens",
897
+ "files-static-check",
898
+ "static-check",
899
+ ];
900
+ for (const toolName of toolNames) {
901
+ const tool = pi.tools.find((candidate: RegisteredTool) => candidate.name === toolName);
902
+ assert.ok(tool, `missing tool ${toolName}`);
903
+ assert.equal(typeof tool?.renderResult, "function", `missing renderResult for ${toolName}`);
904
+ }
905
+
906
+ fs.writeFileSync(path.join(projectBase, "src", "render_result_target.ts"), "export function alpha(): number {\n return 1;\n}\n", "utf8");
907
+ const tool = pi.tools.find((candidate: RegisteredTool) => candidate.name === "files-search");
908
+ assert.ok(tool?.renderResult, "missing renderResult for files-search");
909
+ const params = {
910
+ tag: "FUNCTION",
911
+ pattern: "^alpha$",
912
+ files: ["src/render_result_target.ts"],
913
+ enableLineNumbers: true,
914
+ };
915
+ const result = await executeRegisteredTool(pi, "files-search", projectBase, params);
916
+ const theme: FakeThemeAdapter = {
917
+ fg: (color: string, text: string) => formatFakeThemeForeground(color, text),
918
+ bgFromFg: (color: string, text: string) => formatFakeThemeBackgroundFromForeground(color, text),
919
+ bold: (text: string) => `<bold>${text}</bold>`,
920
+ };
921
+ const compact = tool.renderResult(result, { expanded: false, isPartial: false }, theme, { args: params }).render(120).join("\n");
922
+ const expanded = tool.renderResult(result, { expanded: true, isPartial: false }, theme, { args: params }).render(120).join("\n");
923
+ assert.match(compact, /files-search/);
924
+ assert.match(compact, /tag="FUNCTION"/);
925
+ assert.match(compact, /pattern="\^alpha\$"/);
926
+ assert.match(compact, /files=\["src\/render_result_target\.ts"\]/);
927
+ assert.doesNotMatch(compact, /### FUNCTION/);
928
+ assert.match(expanded, /### FUNCTION: `alpha`/);
929
+ } finally {
930
+ fs.rmSync(projectBase, { recursive: true, force: true });
931
+ }
733
932
  });
734
933
 
735
- test("files-tokens returns token-optimized structured source facts", async () => {
934
+ test("files-tokens returns monolithic token summary text with execution diagnostics", async () => {
736
935
  const { projectBase } = initFixtureRepo({
737
936
  fixtures: [],
738
937
  docs: {
@@ -748,45 +947,20 @@ test("files-tokens returns token-optimized structured source facts", async () =>
748
947
  files: [`${DEFAULT_DOCS_DIR}/REQUIREMENTS.md`, `${DEFAULT_DOCS_DIR}/MISSING.md`],
749
948
  }) as {
750
949
  content?: Array<{ type: string; text?: string }>;
751
- details?: {
752
- summary: {
753
- counted_file_count: number;
754
- skipped_file_count: number;
755
- };
756
- files: Array<{
757
- canonical_path: string;
758
- status: string;
759
- start_line_number: number;
760
- end_line_number: number;
761
- line_count: number;
762
- byte_count: number;
763
- primary_heading_text?: string;
764
- error_message?: string;
765
- }>;
766
- execution: {
767
- stderr: string;
768
- };
769
- };
950
+ details?: { execution: { code: number; stderr_lines?: string[] } };
770
951
  };
771
952
 
772
- const payload = result.details!;
773
- assert.deepEqual(JSON.parse(result.content?.[0]?.text ?? "{}"), JSON.parse(JSON.stringify(payload)));
774
- assert.equal(payload.summary.counted_file_count, 1);
775
- assert.equal(payload.summary.skipped_file_count, 1);
776
- assert.equal(payload.files[0]?.canonical_path, `${DEFAULT_DOCS_DIR}/REQUIREMENTS.md`);
777
- assert.equal(payload.files[0]?.start_line_number, 1);
778
- assert.equal(payload.files[0]?.end_line_number, payload.files[0]?.line_count);
779
- assert.ok((payload.files[0]?.byte_count ?? 0) > 0);
780
- assert.equal(payload.files[0]?.primary_heading_text, "Requirements");
781
- assert.equal(payload.files[1]?.canonical_path, `${DEFAULT_DOCS_DIR}/MISSING.md`);
782
- assert.equal(payload.files[1]?.error_message, "not found");
783
- assert.match(payload.execution.stderr, new RegExp(`skipped: ${DEFAULT_DOCS_DIR.replace("/", "\\/")}/MISSING\\.md: not found`));
953
+ const content = result.content?.[0]?.text ?? "";
954
+ assert.match(content, /Pack Summary/);
955
+ assert.match(content, /REQUIREMENTS\.md/);
956
+ assert.equal(result.details?.execution.code, 0);
957
+ assert.ok(result.details?.execution.stderr_lines?.some((line) => /MISSING\.md/.test(line)));
784
958
  } finally {
785
959
  fs.rmSync(projectBase, { recursive: true, force: true });
786
960
  }
787
961
  });
788
962
 
789
- test("files-tokens defers js-tiktoken loading until execution and returns structured dependency failures", async () => {
963
+ test("files-tokens defers js-tiktoken loading until execution and returns monolithic dependency failures", async () => {
790
964
  const { projectBase } = initFixtureRepo({ fixtures: [] });
791
965
  const missingModuleError = new Error("Cannot find module 'js-tiktoken'");
792
966
  setJsTiktokenModuleLoaderForTests(() => {
@@ -798,35 +972,20 @@ test("files-tokens defers js-tiktoken loading until execution and returns struct
798
972
  const result = await executeRegisteredTool(pi, "files-tokens", projectBase, {
799
973
  files: [`${DEFAULT_DOCS_DIR}/REQUIREMENTS.md`],
800
974
  }) as {
801
- details?: {
802
- summary: {
803
- counted_file_count: number;
804
- skipped_file_count: number;
805
- processable_file_count: number;
806
- };
807
- files: unknown[];
808
- execution: {
809
- code: number;
810
- stderr: string;
811
- };
812
- };
975
+ content?: Array<{ type: string; text?: string }>;
976
+ details?: { execution: { code: number; stderr_lines?: string[] } };
813
977
  };
814
978
 
815
- const payload = result.details!;
816
- assert.equal(payload.summary.processable_file_count, 0);
817
- assert.equal(payload.summary.counted_file_count, 0);
818
- assert.equal(payload.summary.skipped_file_count, 0);
819
- assert.deepEqual(payload.files, []);
820
- assert.equal(payload.execution.code, 1);
821
- assert.match(payload.execution.stderr, /js-tiktoken/);
822
- assert.match(payload.execution.stderr, /npm ci/);
979
+ assert.equal(result.details?.execution.code, 1);
980
+ assert.match(result.content?.[0]?.text ?? "", /js-tiktoken/);
981
+ assert.ok(result.details?.execution.stderr_lines?.some((line) => /npm ci/.test(line)));
823
982
  } finally {
824
983
  setJsTiktokenModuleLoaderForTests(undefined);
825
984
  fs.rmSync(projectBase, { recursive: true, force: true });
826
985
  }
827
986
  });
828
987
 
829
- test("files-references returns structured repository, symbol, and Doxygen facts", async () => {
988
+ test("files-references returns monolithic references markdown", async () => {
830
989
  const { projectBase } = initFixtureRepo({ fixtures: [] });
831
990
  const samplePath = path.join(projectBase, "src", "sample.ts");
832
991
  fs.writeFileSync(samplePath, `/**
@@ -851,66 +1010,22 @@ export function buildSample(input: string): string {
851
1010
  files: ["src/sample.ts", "src/missing.ts"],
852
1011
  }) as {
853
1012
  content?: Array<{ type: string; text?: string }>;
854
- details?: {
855
- summary: {
856
- analyzed_file_count: number;
857
- skipped_file_count: number;
858
- };
859
- repository: {
860
- file_canonical_paths: string[];
861
- };
862
- files: Array<{
863
- canonical_path: string;
864
- status: string;
865
- symbol_count: number;
866
- doxygen_field_count: number;
867
- file_doxygen?: { brief?: string[] };
868
- symbols: Array<{
869
- symbol_name: string;
870
- qualified_name: string;
871
- symbol_kind: string;
872
- line_range: [number, number];
873
- doxygen?: {
874
- brief?: string[];
875
- params?: Array<{ direction: string; parameter_name?: string; value_type?: string; description?: string }>;
876
- returns?: string[];
877
- satisfies_requirement_ids?: string[];
878
- };
879
- }>;
880
- }>;
881
- execution: {
882
- stderr: string;
883
- };
884
- };
1013
+ details?: { execution: { code: number; stderr_lines?: string[] } };
885
1014
  };
886
1015
 
887
- const payload = result.details!;
888
- assert.deepEqual(JSON.parse(result.content?.[0]?.text ?? "{}"), JSON.parse(JSON.stringify(payload)));
889
- assert.equal(payload.summary.analyzed_file_count, 1);
890
- assert.equal(payload.summary.skipped_file_count, 1);
891
- assert.deepEqual(payload.repository.file_canonical_paths, ["src/sample.ts"]);
892
- assert.deepEqual(payload.files.map((file) => file.status), ["analyzed", "skipped"]);
893
- assert.equal(payload.files[0]?.canonical_path, "src/sample.ts");
894
- assert.equal(payload.files[0]?.symbol_count, 1);
895
- assert.ok((payload.files[0]?.doxygen_field_count ?? 0) >= 2);
896
- assert.deepEqual(payload.files[0]?.file_doxygen?.brief, ["Sample source file."]);
897
- assert.equal(payload.files[0]?.symbols[0]?.symbol_name, "buildSample");
898
- assert.equal(payload.files[0]?.symbols[0]?.qualified_name, "buildSample");
899
- assert.equal(payload.files[0]?.symbols[0]?.symbol_kind, "FUNCTION");
900
- assert.deepEqual(payload.files[0]?.symbols[0]?.line_range, [12, 14]);
901
- assert.deepEqual(payload.files[0]?.symbols[0]?.doxygen?.brief, ["Builds the sample value."]);
902
- assert.deepEqual(payload.files[0]?.symbols[0]?.doxygen?.params, [
903
- { direction: "in", parameter_name: "input", value_type: "string", description: "Caller-supplied value." },
904
- ]);
905
- assert.deepEqual(payload.files[0]?.symbols[0]?.doxygen?.returns, ["{string} Upper-cased result."]);
906
- assert.deepEqual(payload.files[0]?.symbols[0]?.doxygen?.satisfies_requirement_ids, ["REQ-011"]);
907
- assert.match(payload.execution.stderr, /skipped: src\/missing\.ts: not found/);
1016
+ const content = result.content?.[0]?.text ?? "";
1017
+ assert.match(content, /# sample\.ts \| TypeScript/);
1018
+ assert.match(content, /## Definitions/);
1019
+ assert.match(content, /buildSample/);
1020
+ assert.match(content, /Caller-supplied value\./);
1021
+ assert.equal(result.details?.execution.code, 0);
1022
+ assert.deepEqual(result.details?.execution.stderr_lines, undefined);
908
1023
  } finally {
909
1024
  fs.rmSync(projectBase, { recursive: true, force: true });
910
1025
  }
911
1026
  });
912
1027
 
913
- test("references returns a structured repository tree for configured source directories", async () => {
1028
+ test("references returns monolithic project references markdown", async () => {
914
1029
  const { projectBase } = initFixtureRepo({ fixtures: [] });
915
1030
  fs.writeFileSync(path.join(projectBase, "src", "alpha.ts"), "export const ALPHA = 1;\n", "utf8");
916
1031
  fs.mkdirSync(path.join(projectBase, "src", "nested"), { recursive: true });
@@ -920,49 +1035,23 @@ test("references returns a structured repository tree for configured source dire
920
1035
  piUsereqExtension(pi);
921
1036
  const result = await executeRegisteredTool(pi, "references", projectBase, {}) as {
922
1037
  content?: Array<{ type: string; text?: string }>;
923
- details?: {
924
- summary: {
925
- analyzed_file_count: number;
926
- total_symbol_count: number;
927
- };
928
- repository: {
929
- source_directory_paths: string[];
930
- file_canonical_paths: string[];
931
- directory_tree: {
932
- node_name: string;
933
- children: Array<{ node_name: string; node_kind: string; children: Array<{ relative_path: string; node_kind: string }> }>;
934
- };
935
- };
936
- files: Array<{
937
- canonical_path: string;
938
- line_range: [number, number];
939
- symbols: Array<{ symbol_name: string; line_range: [number, number] }>;
940
- }>;
941
- };
1038
+ details?: { execution: { code: number; stderr_lines?: string[] } };
942
1039
  };
943
1040
 
944
- const payload = result.details!;
945
- assert.deepEqual(JSON.parse(result.content?.[0]?.text ?? "{}"), JSON.parse(JSON.stringify(payload)));
946
- assert.equal(payload.summary.analyzed_file_count, 2);
947
- assert.equal(payload.summary.total_symbol_count, 1);
948
- assert.deepEqual(payload.repository.source_directory_paths, ["src"]);
949
- assert.deepEqual(payload.repository.file_canonical_paths, ["src/alpha.ts", "src/nested/beta.ts"]);
950
- assert.equal(payload.repository.directory_tree.node_name, ".");
951
- assert.equal(payload.repository.directory_tree.children[0]?.node_name, "src");
952
- assert.equal(payload.repository.directory_tree.children[0]?.node_kind, "directory");
953
- assert.ok(payload.repository.directory_tree.children[0]?.children.some((child) => child.relative_path === "src/alpha.ts"));
954
- assert.ok(payload.repository.directory_tree.children[0]?.children.some((child) => child.relative_path === "src/nested"));
955
- assert.deepEqual(payload.files.map((file) => file.canonical_path), ["src/alpha.ts", "src/nested/beta.ts"]);
956
- assert.deepEqual(payload.files[0]?.line_range, [1, 1]);
957
- assert.equal(payload.files[0]?.symbols.length, 0);
958
- assert.equal(payload.files[1]?.symbols[0]?.symbol_name, "beta");
959
- assert.deepEqual(payload.files[1]?.symbols[0]?.line_range, [1, 3]);
1041
+ const content = result.content?.[0]?.text ?? "";
1042
+ assert.match(content, /# Files Structure/);
1043
+ assert.match(content, /src\/alpha\.ts/);
1044
+ assert.match(content, /src\/nested\/beta\.ts/);
1045
+ assert.match(content, /# alpha\.ts \| TypeScript/);
1046
+ assert.match(content, /# beta\.ts \| TypeScript/);
1047
+ assert.equal(result.details?.execution.code, 0);
1048
+ assert.deepEqual(result.details?.execution.stderr_lines, undefined);
960
1049
  } finally {
961
1050
  fs.rmSync(projectBase, { recursive: true, force: true });
962
1051
  }
963
1052
  });
964
1053
 
965
- test("files-compress returns structured line, symbol, and Doxygen facts", async () => {
1054
+ test("files-compress returns monolithic compression markdown", async () => {
966
1055
  const { projectBase } = initFixtureRepo({ fixtures: [] });
967
1056
  const samplePath = path.join(projectBase, "src", "sample.ts");
968
1057
  fs.writeFileSync(samplePath, `/**
@@ -988,68 +1077,21 @@ export function buildSample(input: string): string {
988
1077
  enableLineNumbers: true,
989
1078
  }) as {
990
1079
  content?: Array<{ type: string; text?: string }>;
991
- details?: {
992
- summary: {
993
- compressed_file_count: number;
994
- skipped_file_count: number;
995
- total_symbol_count: number;
996
- };
997
- repository: {
998
- file_canonical_paths: string[];
999
- };
1000
- files: Array<{
1001
- canonical_path: string;
1002
- status: string;
1003
- line_number_mode: string;
1004
- source_line_count: number;
1005
- compressed_line_count: number;
1006
- file_doxygen?: { brief?: string[] };
1007
- symbols: Array<{
1008
- symbol_name: string;
1009
- symbol_kind: string;
1010
- line_range: [number, number];
1011
- doxygen?: { brief?: string[] };
1012
- }>;
1013
- compressed_lines: Array<{ source_line_number: number; display_text: string; text: string }>;
1014
- compressed_source_text?: string;
1015
- error_reason?: string;
1016
- }>;
1017
- execution: {
1018
- code: number;
1019
- stderr: string;
1020
- };
1021
- };
1080
+ details?: { execution: { code: number; stderr_lines?: string[] } };
1022
1081
  };
1023
1082
 
1024
- const payload = result.details!;
1025
- assert.deepEqual(JSON.parse(result.content?.[0]?.text ?? "{}"), JSON.parse(JSON.stringify(payload)));
1026
- assert.equal(payload.summary.compressed_file_count, 1);
1027
- assert.equal(payload.summary.skipped_file_count, 1);
1028
- assert.equal(payload.summary.total_symbol_count, 1);
1029
- assert.ok(payload.repository.file_canonical_paths.includes("src/sample.ts"));
1030
- assert.equal(payload.files[0]?.canonical_path, "src/sample.ts");
1031
- assert.equal(payload.files[0]?.status, "compressed");
1032
- assert.equal(payload.files[0]?.line_number_mode, "enabled");
1033
- assert.ok((payload.files[0]?.source_line_count ?? 0) > 0);
1034
- assert.ok((payload.files[0]?.compressed_line_count ?? 0) > 0);
1035
- assert.deepEqual(payload.files[0]?.file_doxygen?.brief, ["Sample source file."]);
1036
- assert.equal(payload.files[0]?.symbols[0]?.symbol_name, "buildSample");
1037
- assert.equal(payload.files[0]?.symbols[0]?.symbol_kind, "FUNCTION");
1038
- assert.deepEqual(payload.files[0]?.symbols[0]?.line_range, [11, 14]);
1039
- assert.deepEqual(payload.files[0]?.symbols[0]?.doxygen?.brief, ["Builds the sample value."]);
1040
- assert.equal(payload.files[0]?.compressed_lines[0]?.source_line_number, 11);
1041
- assert.match(payload.files[0]?.compressed_lines[0]?.display_text ?? "", /^11: export function buildSample/);
1042
- assert.match(payload.files[0]?.compressed_source_text ?? "", /^11: export function buildSample/m);
1043
- assert.equal(payload.files[1]?.status, "skipped");
1044
- assert.equal(payload.files[1]?.error_reason, "not_found");
1045
- assert.equal(payload.execution.code, 0);
1046
- assert.match(payload.execution.stderr, /skipped: src\/missing\.ts: not_found/);
1083
+ const content = result.content?.[0]?.text ?? "";
1084
+ assert.match(content, /@@@ src\/sample\.ts \| typescript/);
1085
+ assert.match(content, /> Lines: 11-14/);
1086
+ assert.match(content, /^11: export function buildSample/m);
1087
+ assert.equal(result.details?.execution.code, 0);
1088
+ assert.deepEqual(result.details?.execution.stderr_lines, undefined);
1047
1089
  } finally {
1048
1090
  fs.rmSync(projectBase, { recursive: true, force: true });
1049
1091
  }
1050
1092
  });
1051
1093
 
1052
- test("compress returns a structured repository-scoped compression payload", async () => {
1094
+ test("compress returns monolithic repository-scoped compression markdown", async () => {
1053
1095
  const { projectBase } = initFixtureRepo({ fixtures: [] });
1054
1096
  fs.writeFileSync(
1055
1097
  path.join(projectBase, "src", "alpha.ts"),
@@ -1069,45 +1111,21 @@ test("compress returns a structured repository-scoped compression payload", asyn
1069
1111
  enableLineNumbers: false,
1070
1112
  }) as {
1071
1113
  content?: Array<{ type: string; text?: string }>;
1072
- details?: {
1073
- summary: {
1074
- compressed_file_count: number;
1075
- total_compressed_line_count: number;
1076
- };
1077
- repository: {
1078
- source_directory_paths: string[];
1079
- file_canonical_paths: string[];
1080
- };
1081
- files: Array<{
1082
- canonical_path: string;
1083
- line_number_mode: string;
1084
- compressed_source_text?: string;
1085
- compressed_lines: Array<{ display_text: string }>;
1086
- }>;
1087
- execution: {
1088
- code: number;
1089
- stderr: string;
1090
- };
1091
- };
1114
+ details?: { execution: { code: number; stderr_lines?: string[] } };
1092
1115
  };
1093
1116
 
1094
- const payload = result.details!;
1095
- assert.deepEqual(JSON.parse(result.content?.[0]?.text ?? "{}"), JSON.parse(JSON.stringify(payload)));
1096
- assert.equal(payload.summary.compressed_file_count, 2);
1097
- assert.ok((payload.summary.total_compressed_line_count ?? 0) >= 2);
1098
- assert.deepEqual(payload.repository.source_directory_paths, ["src"]);
1099
- assert.deepEqual(payload.repository.file_canonical_paths, ["src/alpha.ts", "src/nested/beta.ts"]);
1100
- assert.equal(payload.files[0]?.line_number_mode, "disabled");
1101
- assert.doesNotMatch(payload.files[1]?.compressed_source_text ?? "", /^\d+:/m);
1102
- assert.match(payload.files[1]?.compressed_lines[0]?.display_text ?? "", /^export function buildBeta/);
1103
- assert.equal(payload.execution.code, 0);
1104
- assert.equal(payload.execution.stderr, "");
1117
+ const content = result.content?.[0]?.text ?? "";
1118
+ assert.match(content, /@@@ src\/alpha\.ts \| typescript/);
1119
+ assert.match(content, /@@@ src\/nested\/beta\.ts \| typescript/);
1120
+ assert.match(content, /^export function buildBeta/m);
1121
+ assert.equal(result.details?.execution.code, 0);
1122
+ assert.deepEqual(result.details?.execution.stderr_lines, undefined);
1105
1123
  } finally {
1106
1124
  fs.rmSync(projectBase, { recursive: true, force: true });
1107
1125
  }
1108
1126
  });
1109
1127
 
1110
- test("files-find returns structured match, code-line, and Doxygen facts", async () => {
1128
+ test("files-search returns monolithic search markdown", async () => {
1111
1129
  const { projectBase } = initFixtureRepo({ fixtures: [] });
1112
1130
  const samplePath = path.join(projectBase, "src", "sample.ts");
1113
1131
  fs.writeFileSync(samplePath, `/**
@@ -1128,86 +1146,29 @@ export function buildSample(input: string): string {
1128
1146
  try {
1129
1147
  const pi = createFakePi();
1130
1148
  piUsereqExtension(pi);
1131
- const result = await executeRegisteredTool(pi, "files-find", projectBase, {
1149
+ const result = await executeRegisteredTool(pi, "files-search", projectBase, {
1132
1150
  tag: "FUNCTION",
1133
1151
  pattern: "^buildSample$",
1134
1152
  files: ["src/sample.ts", "src/missing.ts"],
1135
1153
  enableLineNumbers: true,
1136
1154
  }) as {
1137
1155
  content?: Array<{ type: string; text?: string }>;
1138
- details?: {
1139
- summary: {
1140
- search_status: string;
1141
- matched_file_count: number;
1142
- skipped_file_count: number;
1143
- total_match_count: number;
1144
- };
1145
- repository: {
1146
- file_canonical_paths: string[];
1147
- };
1148
- files: Array<{
1149
- canonical_path: string;
1150
- status: string;
1151
- match_count: number;
1152
- supported_tags: string[];
1153
- file_doxygen?: { brief?: string[] };
1154
- matches: Array<{
1155
- symbol_name: string;
1156
- qualified_name: string;
1157
- symbol_kind: string;
1158
- line_range: [number, number];
1159
- code_line_count: number;
1160
- code_lines: Array<{ source_line_number: number; display_text: string; text: string }>;
1161
- stripped_source_text?: string;
1162
- doxygen?: {
1163
- brief?: string[];
1164
- params?: Array<{ direction: string; parameter_name?: string; value_type?: string; description?: string }>;
1165
- returns?: string[];
1166
- satisfies_requirement_ids?: string[];
1167
- };
1168
- }>;
1169
- }>;
1170
- execution: {
1171
- code: number;
1172
- stderr: string;
1173
- };
1174
- };
1156
+ details?: { execution: { code: number; stderr_lines?: string[] } };
1175
1157
  };
1176
1158
 
1177
- const payload = result.details!;
1178
- assert.deepEqual(JSON.parse(result.content?.[0]?.text ?? "{}"), JSON.parse(JSON.stringify(payload)));
1179
- assert.equal(payload.summary.search_status, "matched");
1180
- assert.equal(payload.summary.matched_file_count, 1);
1181
- assert.equal(payload.summary.skipped_file_count, 1);
1182
- assert.equal(payload.summary.total_match_count, 1);
1183
- assert.deepEqual(payload.repository.file_canonical_paths, ["src/sample.ts", "src/missing.ts"]);
1184
- assert.deepEqual(payload.files.map((file) => file.status), ["matched", "skipped"]);
1185
- assert.equal(payload.files[0]?.canonical_path, "src/sample.ts");
1186
- assert.equal(payload.files[0]?.match_count, 1);
1187
- assert.ok(payload.files[0]?.supported_tags.includes("FUNCTION"));
1188
- assert.deepEqual(payload.files[0]?.file_doxygen?.brief, ["Sample source file."]);
1189
- assert.equal(payload.files[0]?.matches[0]?.symbol_name, "buildSample");
1190
- assert.equal(payload.files[0]?.matches[0]?.qualified_name, "buildSample");
1191
- assert.equal(payload.files[0]?.matches[0]?.symbol_kind, "FUNCTION");
1192
- assert.deepEqual(payload.files[0]?.matches[0]?.line_range, [12, 14]);
1193
- assert.ok((payload.files[0]?.matches[0]?.code_line_count ?? 0) > 0);
1194
- assert.equal(payload.files[0]?.matches[0]?.code_lines[0]?.source_line_number, 12);
1195
- assert.match(payload.files[0]?.matches[0]?.code_lines[0]?.display_text ?? "", /^12: export function buildSample/);
1196
- assert.match(payload.files[0]?.matches[0]?.stripped_source_text ?? "", /^12: export function buildSample/m);
1197
- assert.deepEqual(payload.files[0]?.matches[0]?.doxygen?.brief, ["Builds the sample value."]);
1198
- assert.deepEqual(payload.files[0]?.matches[0]?.doxygen?.params, [
1199
- { direction: "in", parameter_name: "input", value_type: "string", description: "Caller-supplied value." },
1200
- ]);
1201
- assert.deepEqual(payload.files[0]?.matches[0]?.doxygen?.returns, ["{string} Upper-cased result."]);
1202
- assert.deepEqual(payload.files[0]?.matches[0]?.doxygen?.satisfies_requirement_ids, ["REQ-089"]);
1203
- assert.equal(payload.execution.code, 0);
1204
- assert.match(payload.execution.stderr, /skipped: src\/missing\.ts: not_found/);
1159
+ const content = result.content?.[0]?.text ?? "";
1160
+ assert.match(content, /### FUNCTION: `buildSample`/);
1161
+ assert.match(content, /> Signature: `export function buildSample\(input: string\): string`/);
1162
+ assert.match(content, /> Lines: 12-14/);
1163
+ assert.match(content, /^12: export function buildSample/m);
1164
+ assert.equal(result.details?.execution.code, 0);
1165
+ assert.deepEqual(result.details?.execution.stderr_lines, undefined);
1205
1166
  } finally {
1206
1167
  fs.rmSync(projectBase, { recursive: true, force: true });
1207
1168
  }
1208
1169
  });
1209
1170
 
1210
- test("find returns a structured repository-scoped construct-search payload", async () => {
1171
+ test("search returns monolithic repository-scoped construct-search markdown", async () => {
1211
1172
  const { projectBase } = initFixtureRepo({ fixtures: [] });
1212
1173
  fs.writeFileSync(
1213
1174
  path.join(projectBase, "src", "alpha.ts"),
@@ -1230,63 +1191,120 @@ export function buildAlpha(): number {
1230
1191
  try {
1231
1192
  const pi = createFakePi();
1232
1193
  piUsereqExtension(pi);
1233
- const result = await executeRegisteredTool(pi, "find", projectBase, {
1194
+ const result = await executeRegisteredTool(pi, "search", projectBase, {
1234
1195
  tag: "FUNCTION",
1235
1196
  pattern: "^build",
1236
1197
  enableLineNumbers: false,
1237
1198
  }) as {
1238
1199
  content?: Array<{ type: string; text?: string }>;
1239
- details?: {
1240
- summary: {
1241
- search_status: string;
1242
- matched_file_count: number;
1243
- no_match_file_count: number;
1244
- total_match_count: number;
1245
- };
1246
- repository: {
1247
- source_directory_paths: string[];
1248
- file_canonical_paths: string[];
1249
- };
1250
- files: Array<{
1251
- canonical_path: string;
1252
- status: string;
1253
- matches: Array<{
1254
- symbol_name: string;
1255
- code_lines: Array<{ display_text: string }>;
1256
- stripped_source_text?: string;
1257
- }>;
1258
- }>;
1259
- execution: {
1260
- code: number;
1261
- stderr: string;
1262
- };
1263
- };
1200
+ details?: { execution: { code: number; stderr_lines?: string[] } };
1201
+ };
1202
+
1203
+ const content = result.content?.[0]?.text ?? "";
1204
+ assert.match(content, /### FUNCTION: `buildAlpha`/);
1205
+ assert.match(content, /export function buildAlpha/);
1206
+ assert.doesNotMatch(content, /helperBeta/);
1207
+ assert.equal(result.details?.execution.code, 0);
1208
+ assert.deepEqual(result.details?.execution.stderr_lines, undefined);
1209
+ } finally {
1210
+ fs.rmSync(projectBase, { recursive: true, force: true });
1211
+ }
1212
+ });
1213
+
1214
+ test("source-extraction agent tools preserve leading tabs in emitted content", async () => {
1215
+ const { projectBase } = initFixtureRepo({ fixtures: [] });
1216
+ const samplePath = path.join(projectBase, "src", "sample.go");
1217
+ fs.writeFileSync(samplePath, `package main
1218
+
1219
+ import (
1220
+ "bytes"
1221
+ "context"
1222
+ "fmt"
1223
+ )
1224
+
1225
+ type Runner interface {
1226
+ Run(ctx context.Context) error
1227
+ }
1228
+
1229
+ type Server struct{}
1230
+
1231
+ func (s *Server) Start(ctx context.Context) error {
1232
+ var buffer bytes.Buffer
1233
+ fmt.Println("start", buffer.Len())
1234
+ return nil
1235
+ }
1236
+ `, "utf8");
1237
+ try {
1238
+ const pi = createFakePi();
1239
+ piUsereqExtension(pi);
1240
+
1241
+ const filesReferencesResult = await executeRegisteredTool(pi, "files-references", projectBase, {
1242
+ files: ["src/sample.go"],
1243
+ }) as {
1244
+ content?: Array<{ type: string; text?: string }>;
1245
+ details?: { execution: { code: number } };
1246
+ };
1247
+ assert.match(filesReferencesResult.content?.[0]?.text ?? "", /\tvar buffer bytes\.Buffer/);
1248
+ assert.equal(filesReferencesResult.details?.execution.code, 0);
1249
+
1250
+ const referencesResult = await executeRegisteredTool(pi, "references", projectBase, {}) as {
1251
+ content?: Array<{ type: string; text?: string }>;
1252
+ details?: { execution: { code: number } };
1253
+ };
1254
+ assert.match(referencesResult.content?.[0]?.text ?? "", /\tvar buffer bytes\.Buffer/);
1255
+ assert.equal(referencesResult.details?.execution.code, 0);
1256
+
1257
+ const filesCompressResult = await executeRegisteredTool(pi, "files-compress", projectBase, {
1258
+ files: ["src/sample.go"],
1259
+ enableLineNumbers: true,
1260
+ }) as {
1261
+ content?: Array<{ type: string; text?: string }>;
1262
+ details?: { execution: { code: number } };
1263
+ };
1264
+ assert.match(filesCompressResult.content?.[0]?.text ?? "", /\d+: \t"context"/);
1265
+ assert.equal(filesCompressResult.details?.execution.code, 0);
1266
+
1267
+ const compressResult = await executeRegisteredTool(pi, "compress", projectBase, {
1268
+ enableLineNumbers: true,
1269
+ }) as {
1270
+ content?: Array<{ type: string; text?: string }>;
1271
+ details?: { execution: { code: number } };
1272
+ };
1273
+ assert.match(compressResult.content?.[0]?.text ?? "", /\d+: \t"context"/);
1274
+ assert.equal(compressResult.details?.execution.code, 0);
1275
+
1276
+ const filesSearchResult = await executeRegisteredTool(pi, "files-search", projectBase, {
1277
+ tag: "METHOD|FUNCTION",
1278
+ pattern: "^Start$",
1279
+ files: ["src/sample.go"],
1280
+ enableLineNumbers: true,
1281
+ }) as {
1282
+ content?: Array<{ type: string; text?: string }>;
1283
+ details?: { execution: { code: number } };
1264
1284
  };
1285
+ assert.match(filesSearchResult.content?.[0]?.text ?? "", /\d+: \tfmt\.Println\("start", buffer\.Len\(\)\)/);
1286
+ assert.equal(filesSearchResult.details?.execution.code, 0);
1265
1287
 
1266
- const payload = result.details!;
1267
- assert.deepEqual(JSON.parse(result.content?.[0]?.text ?? "{}"), JSON.parse(JSON.stringify(payload)));
1268
- assert.equal(payload.summary.search_status, "matched");
1269
- assert.equal(payload.summary.matched_file_count, 1);
1270
- assert.equal(payload.summary.no_match_file_count, 1);
1271
- assert.equal(payload.summary.total_match_count, 1);
1272
- assert.deepEqual(payload.repository.source_directory_paths, ["src"]);
1273
- assert.deepEqual(payload.repository.file_canonical_paths, ["src/alpha.ts", "src/nested/beta.ts"]);
1274
- assert.deepEqual(payload.files.map((file) => file.status), ["matched", "no_match"]);
1275
- assert.equal(payload.files[0]?.matches[0]?.symbol_name, "buildAlpha");
1276
- assert.match(payload.files[0]?.matches[0]?.code_lines[0]?.display_text ?? "", /^export function buildAlpha/);
1277
- assert.doesNotMatch(payload.files[0]?.matches[0]?.stripped_source_text ?? "", /^\d+:/m);
1278
- assert.equal(payload.execution.code, 0);
1279
- assert.match(payload.execution.stderr, /info: no_match: src\/nested\/beta\.ts/);
1288
+ const searchResult = await executeRegisteredTool(pi, "search", projectBase, {
1289
+ tag: "METHOD|FUNCTION",
1290
+ pattern: "^Start$",
1291
+ enableLineNumbers: true,
1292
+ }) as {
1293
+ content?: Array<{ type: string; text?: string }>;
1294
+ details?: { execution: { code: number } };
1295
+ };
1296
+ assert.match(searchResult.content?.[0]?.text ?? "", /\d+: \tfmt\.Println\("start", buffer\.Len\(\)\)/);
1297
+ assert.equal(searchResult.details?.execution.code, 0);
1280
1298
  } finally {
1281
1299
  fs.rmSync(projectBase, { recursive: true, force: true });
1282
1300
  }
1283
1301
  });
1284
1302
 
1285
- test("path, static-check, git, docs, and worktree tools return structured JSON payloads", async () => {
1303
+ test("static-check tools return monolithic text payloads with minimal execution details", async () => {
1286
1304
  const { projectBase } = initFixtureRepo({
1287
1305
  fixtures: [],
1288
1306
  staticCheck: {
1289
- TypeScript: [{ module: "Dummy" }],
1307
+ TypeScript: createStaticCheckLanguageConfig([{ module: "Dummy" }], "enable"),
1290
1308
  },
1291
1309
  });
1292
1310
  fs.writeFileSync(path.join(projectBase, "src", "check.ts"), "export const VALUE = 1;\n", "utf8");
@@ -1298,97 +1316,46 @@ test("path, static-check, git, docs, and worktree tools return structured JSON p
1298
1316
  const pi = createFakePi();
1299
1317
  piUsereqExtension(pi);
1300
1318
 
1301
- const gitPathResult = await executeRegisteredTool(pi, "git-path", projectBase) as {
1302
- content?: Array<{ text?: string }>;
1303
- details?: {
1304
- result: { path_value: string; path_present: boolean };
1305
- execution: { code: number };
1306
- };
1307
- };
1308
- assert.deepEqual(JSON.parse(gitPathResult.content?.[0]?.text ?? "{}"), JSON.parse(JSON.stringify(gitPathResult.details)));
1309
- assert.equal(gitPathResult.details?.result.path_value, projectBase);
1310
- assert.equal(gitPathResult.details?.result.path_present, true);
1311
- assert.equal(gitPathResult.details?.execution.code, 0);
1312
-
1313
- const basePathResult = await executeRegisteredTool(pi, "get-base-path", projectBase) as {
1314
- details?: {
1315
- result: { path_value: string };
1316
- execution: { code: number };
1317
- };
1318
- };
1319
- assert.equal(basePathResult.details?.result.path_value, projectBase);
1320
- assert.equal(basePathResult.details?.execution.code, 0);
1321
-
1322
- const gitCheckResult = await executeRegisteredTool(pi, "git-check", projectBase) as {
1323
- details?: { result: { git_path_present: boolean; status: string }; execution: { code: number } };
1324
- };
1325
- assert.equal(gitCheckResult.details?.result.git_path_present, true);
1326
- assert.equal(gitCheckResult.details?.result.status, "clean");
1327
- assert.equal(gitCheckResult.details?.execution.code, 0);
1328
-
1329
- const docsCheckResult = await executeRegisteredTool(pi, "docs-check", projectBase) as {
1330
- details?: {
1331
- summary: { missing_file_count: number; present_file_count: number };
1332
- files: Array<{ file_name: string; status: string; prompt_command: string }>;
1333
- execution: { code: number };
1334
- };
1335
- };
1336
- assert.equal(docsCheckResult.details?.summary.missing_file_count, 0);
1337
- assert.equal(docsCheckResult.details?.summary.present_file_count, 3);
1338
- assert.deepEqual(docsCheckResult.details?.files.map((file) => file.file_name), ["REQUIREMENTS.md", "WORKFLOW.md", "REFERENCES.md"]);
1339
- assert.ok(docsCheckResult.details?.files.every((file) => file.status === "present"));
1340
- assert.equal(docsCheckResult.details?.execution.code, 0);
1341
-
1342
1319
  const filesStaticCheckResult = await executeRegisteredTool(pi, "files-static-check", projectBase, {
1343
1320
  files: ["src/check.ts", "src/missing.ts"],
1344
1321
  }) as {
1345
- details?: {
1346
- summary: { selected_file_count: number; skipped_file_count: number; total_configured_checker_count: number };
1347
- files: Array<{ canonical_path: string; language_name?: string; status: string; configured_checker_modules: string[] }>;
1348
- execution: { code: number };
1349
- };
1322
+ content?: Array<{ type: string; text?: string }>;
1323
+ details?: { execution: { code: number; stderr_lines?: string[] } };
1350
1324
  };
1351
- assert.equal(filesStaticCheckResult.details?.summary.selected_file_count, 1);
1352
- assert.equal(filesStaticCheckResult.details?.summary.skipped_file_count, 1);
1353
- assert.equal(filesStaticCheckResult.details?.summary.total_configured_checker_count, 1);
1354
- assert.equal(filesStaticCheckResult.details?.files[0]?.canonical_path, "src/check.ts");
1355
- assert.equal(filesStaticCheckResult.details?.files[0]?.language_name, "TypeScript");
1356
- assert.deepEqual(filesStaticCheckResult.details?.files[0]?.configured_checker_modules, ["Dummy"]);
1357
- assert.equal(filesStaticCheckResult.details?.files[1]?.status, "skipped");
1325
+ assert.match(filesStaticCheckResult.content?.[0]?.text ?? "", /src\/missing\.ts/);
1358
1326
  assert.equal(filesStaticCheckResult.details?.execution.code, 0);
1327
+ assert.ok(filesStaticCheckResult.details?.execution.stderr_lines?.some((line) => /src\/missing\.ts/.test(line)));
1359
1328
 
1360
1329
  const staticCheckResult = await executeRegisteredTool(pi, "static-check", projectBase) as {
1361
- details?: {
1362
- summary: { selected_file_count: number };
1363
- files: Array<{ canonical_path: string; status: string }>;
1364
- execution: { code: number };
1365
- };
1330
+ content?: Array<{ type: string; text?: string }>;
1331
+ details?: { execution: { code: number; stderr_lines?: string[] } };
1366
1332
  };
1367
- assert.equal(staticCheckResult.details?.summary.selected_file_count, 1);
1368
- assert.equal(staticCheckResult.details?.files[0]?.canonical_path, "src/check.ts");
1369
- assert.equal(staticCheckResult.details?.files[0]?.status, "selected");
1333
+ assert.equal(staticCheckResult.content?.[0]?.text ?? "", "");
1370
1334
  assert.equal(staticCheckResult.details?.execution.code, 0);
1371
-
1372
- const gitWtNameResult = await executeRegisteredTool(pi, "git-wt-name", projectBase) as {
1373
- details?: { result: { worktree_name?: string }; execution: { code: number } };
1374
- };
1375
- const wtName = gitWtNameResult.details?.result.worktree_name ?? "";
1376
- assert.match(wtName, /^useReq-/);
1377
- assert.equal(gitWtNameResult.details?.execution.code, 0);
1378
-
1379
- const gitWtCreateResult = await executeRegisteredTool(pi, "git-wt-create", projectBase, { wtName }) as {
1380
- details?: { result: { worktree_name: string; worktree_path: string }; execution: { code: number } };
1381
- };
1382
- assert.equal(gitWtCreateResult.details?.result.worktree_name, wtName);
1383
- assert.match(gitWtCreateResult.details?.result.worktree_path ?? "", new RegExp(`${wtName}$`));
1384
- assert.equal(gitWtCreateResult.details?.execution.code, 0);
1385
-
1386
- const gitWtDeleteResult = await executeRegisteredTool(pi, "git-wt-delete", projectBase, { wtName }) as {
1387
- details?: { result: { worktree_name: string; worktree_path: string }; execution: { code: number } };
1335
+ assert.deepEqual(staticCheckResult.details?.execution.stderr_lines, undefined);
1336
+
1337
+ fs.writeFileSync(
1338
+ getProjectConfigPath(projectBase),
1339
+ `${JSON.stringify({
1340
+ ...getDefaultConfig(projectBase),
1341
+ "docs-dir": DEFAULT_DOCS_DIR,
1342
+ "tests-dir": "tests",
1343
+ "src-dir": ["src"],
1344
+ "static-check": {
1345
+ TypeScript: createStaticCheckLanguageConfig([{ module: "Dummy" }], "disable"),
1346
+ },
1347
+ }, null, 2)}\n`,
1348
+ "utf8",
1349
+ );
1350
+ const disabledFilesStaticCheckResult = await executeRegisteredTool(pi, "files-static-check", projectBase, {
1351
+ files: ["src/check.ts"],
1352
+ }) as {
1353
+ content?: Array<{ type: string; text?: string }>;
1354
+ details?: { execution: { code: number; stderr_lines?: string[] } };
1388
1355
  };
1389
- assert.equal(gitWtDeleteResult.details?.result.worktree_name, wtName);
1390
- assert.match(gitWtDeleteResult.details?.result.worktree_path ?? "", new RegExp(`${wtName}$`));
1391
- assert.equal(gitWtDeleteResult.details?.execution.code, 0);
1356
+ assert.equal(disabledFilesStaticCheckResult.content?.[0]?.text ?? "", "");
1357
+ assert.equal(disabledFilesStaticCheckResult.details?.execution.code, 0);
1358
+ assert.deepEqual(disabledFilesStaticCheckResult.details?.execution.stderr_lines, undefined);
1392
1359
  } finally {
1393
1360
  fs.rmSync(projectBase, { recursive: true, force: true });
1394
1361
  }
@@ -1407,6 +1374,57 @@ test("configuration menu saves updated docs-dir in project config", async () =>
1407
1374
  assert.equal(config["docs-dir"], "docs/custom");
1408
1375
  });
1409
1376
 
1377
+ test("configuration menu persists trailing-slash-free relative directory values", async () => {
1378
+ const cwd = createTempDir("pi-usereq-menu-path-contracts-");
1379
+ fs.mkdirSync(path.dirname(getProjectConfigPath(cwd)), { recursive: true });
1380
+ const pi = createFakePi();
1381
+ piUsereqExtension(pi);
1382
+ const command = pi.commands.get("pi-usereq");
1383
+ assert.ok(command);
1384
+ const ctx = createFakeCtx(cwd, {
1385
+ selects: [
1386
+ "docs-dir",
1387
+ "tests-dir",
1388
+ "src-dir",
1389
+ "add-src-dir-entry",
1390
+ "Save and close",
1391
+ "Save and close",
1392
+ ],
1393
+ inputs: ["docs/custom/", "tests/custom/", "src/custom/"],
1394
+ });
1395
+
1396
+ await command!.handler("", ctx);
1397
+
1398
+ const config = JSON.parse(fs.readFileSync(getProjectConfigPath(cwd), "utf8"));
1399
+ assert.equal(config["docs-dir"], "docs/custom");
1400
+ assert.equal(config["tests-dir"], "tests/custom");
1401
+ assert.deepEqual(config["src-dir"], ["src", "src/custom"]);
1402
+ });
1403
+
1404
+ test("configuration menu forces worktree rows off and dim when auto git commit is disabled", async () => {
1405
+ const cwd = createTempDir("pi-usereq-menu-worktree-");
1406
+ fs.mkdirSync(path.dirname(getProjectConfigPath(cwd)), { recursive: true });
1407
+ const pi = createFakePi();
1408
+ piUsereqExtension(pi);
1409
+ const command = pi.commands.get("pi-usereq");
1410
+ assert.ok(command);
1411
+ const ctx = createFakeCtx(cwd, {
1412
+ selects: ["Auto git commit", "Git worktree", "Worktree prefix", "Save and close"],
1413
+ inputs: ["custom-prefix-"],
1414
+ });
1415
+
1416
+ await command!.handler("", ctx);
1417
+
1418
+ const config = JSON.parse(fs.readFileSync(getProjectConfigPath(cwd), "utf8"));
1419
+ const defaultConfig = getDefaultConfig(cwd);
1420
+ const renderedLockedMenu = (ctx.__state.customRenderLines[1] ?? []).join("\n");
1421
+ assert.equal(config.AUTO_GIT_COMMIT, "disable");
1422
+ assert.equal(config.GIT_WORKTREE_ENABLED, "disable");
1423
+ assert.equal(config.GIT_WORKTREE_PREFIX, defaultConfig.GIT_WORKTREE_PREFIX);
1424
+ assert.match(renderedLockedMenu, /<dim>Git worktree/);
1425
+ assert.match(renderedLockedMenu, /<dim>Worktree prefix/);
1426
+ });
1427
+
1410
1428
  test("configuration menu omits prompt-delivery controls and persisted config omits removed runtime fields", async () => {
1411
1429
  const cwd = createTempDir("pi-usereq-menu-current-session-");
1412
1430
  fs.mkdirSync(path.dirname(getProjectConfigPath(cwd)), { recursive: true });
@@ -1419,11 +1437,8 @@ test("configuration menu omits prompt-delivery controls and persisted config omi
1419
1437
  await command!.handler("", ctx);
1420
1438
 
1421
1439
  const menuItems = ctx.__state.selectCalls[0]?.items ?? [];
1422
- const config = JSON.parse(fs.readFileSync(getProjectConfigPath(cwd), "utf8"));
1423
1440
  assert.equal(menuItems.some((item) => item.includes("reset-context")), false);
1424
- assert.equal(Object.prototype.hasOwnProperty.call(config, "reset-context"), false);
1425
- assert.equal(Object.prototype.hasOwnProperty.call(config, "base-path"), false);
1426
- assert.equal(Object.prototype.hasOwnProperty.call(config, "git-path"), false);
1441
+ assert.equal(fs.existsSync(getProjectConfigPath(cwd)), false);
1427
1442
  });
1428
1443
 
1429
1444
  test("configuration menus expose show-config ordering and omit overview or notification-reference rows", async () => {
@@ -1435,26 +1450,33 @@ test("configuration menus expose show-config ordering and omit overview or notif
1435
1450
  piUsereqExtension(pi);
1436
1451
  const command = pi.commands.get("pi-usereq");
1437
1452
  assert.ok(command);
1438
- const ctx = createFakeCtx(cwd, { selects: ["show-config", "notifications", "Save and close", "Save and close"] });
1453
+ const ctx = createFakeCtx(cwd, { selects: ["notifications", "Save and close", "Save and close"] });
1439
1454
 
1440
1455
  await command!.handler("", ctx);
1441
1456
 
1442
1457
  const renderedMenu = (ctx.__state.customRenderLines[0] ?? []).join("\n");
1458
+ const renderedNotificationsMenu = (ctx.__state.customRenderLines[1] ?? []).join("\n");
1443
1459
  assert.deepEqual(ctx.__state.selectCalls[0]?.items ?? [], [
1444
1460
  "Document directory",
1445
1461
  "Source-code directories",
1446
1462
  "Unit tests directory",
1463
+ "Auto git commit",
1464
+ "Git worktree",
1465
+ "Worktree prefix",
1447
1466
  "Language static code checkers",
1448
1467
  "Enable tools",
1449
1468
  "Notifications",
1469
+ "Debug",
1450
1470
  "Show configuration",
1451
1471
  "Reset defaults",
1452
- "Save and close",
1453
1472
  ]);
1454
1473
  assert.ok(renderedMenu.includes(buildExpectedShowConfigPath(cwd)));
1455
1474
  assert.ok(renderedMenu.includes("notification:off • sound:none • pushover:off"));
1475
+ assert.ok(renderedMenu.includes("disable"));
1456
1476
  assert.doesNotMatch(renderedMenu, /beep:/);
1457
- assert.deepEqual(ctx.__state.selectCalls[2]?.items ?? [], [
1477
+ assert.match(renderedNotificationsMenu, /<dim>Enable pushover/);
1478
+ assert.match(renderedNotificationsMenu, /<dim>off<\/dim>/);
1479
+ assert.deepEqual(ctx.__state.selectCalls[1]?.items ?? [], [
1458
1480
  "Enable notification",
1459
1481
  "Notification events",
1460
1482
  "Notify command",
@@ -1472,12 +1494,34 @@ test("configuration menus expose show-config ordering and omit overview or notif
1472
1494
  "Pushover User Key/Delivery Group Key",
1473
1495
  "Pushover Token/API Token Key",
1474
1496
  "Reset defaults",
1475
- "Save and close",
1476
1497
  ]);
1477
1498
  });
1478
1499
 
1479
- test("notifications menu preserves focus on toggled and edited rows", async () => {
1480
- const cwd = createTempDir("pi-usereq-menu-focus-");
1500
+ test("show configuration saves pending config, closes the menu, and writes the persisted project config file text into the editor", async () => {
1501
+ const cwd = createTempDir("pi-usereq-menu-show-config-");
1502
+ fs.mkdirSync(path.dirname(getProjectConfigPath(cwd)), { recursive: true });
1503
+ const pi = createFakePi();
1504
+ piUsereqExtension(pi);
1505
+ const command = pi.commands.get("pi-usereq");
1506
+ assert.ok(command);
1507
+ const ctx = createFakeCtx(cwd, {
1508
+ selects: ["docs-dir", "show-config"],
1509
+ inputs: ["docs/custom"],
1510
+ });
1511
+
1512
+ await command!.handler("", ctx);
1513
+
1514
+ const persistedText = fs.readFileSync(getProjectConfigPath(cwd), "utf8");
1515
+ assert.equal(ctx.__state.editorText, persistedText);
1516
+ assert.match(ctx.__state.editorText, /"docs-dir": "docs\/custom"/);
1517
+ assert.equal(
1518
+ ctx.__state.selectCalls.filter((call) => call.title === "pi-usereq").length,
1519
+ 2,
1520
+ );
1521
+ });
1522
+
1523
+ test("descendant configuration menus end with Reset defaults only", async () => {
1524
+ const cwd = createTempDir("pi-usereq-menu-terminal-rows-");
1481
1525
  fs.mkdirSync(path.dirname(getProjectConfigPath(cwd)), { recursive: true });
1482
1526
  const pi = createFakePi();
1483
1527
  piUsereqExtension(pi);
@@ -1486,37 +1530,50 @@ test("notifications menu preserves focus on toggled and edited rows", async () =
1486
1530
  const ctx = createFakeCtx(cwd, {
1487
1531
  selects: [
1488
1532
  "notifications",
1489
- "Notification events",
1490
- "Prompt failed",
1533
+ "Enable sound",
1534
+ "Save and close",
1535
+ "Pushover priority",
1536
+ "Save and close",
1537
+ "Save and close",
1538
+ "Enable tools",
1539
+ "Enable tools",
1540
+ "Save and close",
1541
+ "Save and close",
1542
+ "Debug",
1543
+ "Save and close",
1544
+ "Language static code checkers",
1545
+ "Add static code checker",
1546
+ "Save and close",
1547
+ "Remove static code checker",
1548
+ "Save and close",
1549
+ "Save and close",
1550
+ "Source-code directories",
1551
+ "Remove source-code directory",
1491
1552
  "Save and close",
1492
- "Notify command",
1493
1553
  "Save and close",
1494
1554
  "Save and close",
1495
1555
  ],
1496
- inputs: ["echo notify"],
1497
1556
  });
1498
1557
 
1499
1558
  await command!.handler("", ctx);
1500
1559
 
1501
- const notificationCalls = ctx.__state.selectCalls.filter((call) => call.title === "Notifications");
1502
- assert.equal(notificationCalls[0]?.selectedChoiceId, "notify-enabled");
1503
- assert.equal(notificationCalls[1]?.selectedChoiceId, "notification-events");
1504
- assert.equal(notificationCalls[2]?.selectedChoiceId, "notify-command");
1560
+ const getTerminalRows = (title: string, marker?: string): string[] => {
1561
+ const call = ctx.__state.selectCalls.find((entry) => entry.title === title && (marker === undefined || entry.items.includes(marker)));
1562
+ assert.ok(call, `missing menu ${title}`);
1563
+ return call.items.slice(-1);
1564
+ };
1505
1565
 
1506
- const notificationEventCalls = ctx.__state.selectCalls.filter((call) => call.title === "Notification events");
1507
- assert.deepEqual(notificationEventCalls[0]?.items ?? [], [
1508
- "Prompt completed",
1509
- "Prompt interrupted",
1510
- "Prompt failed",
1511
- "Reset defaults",
1512
- "Save and close",
1513
- ]);
1514
- assert.equal(notificationEventCalls[0]?.selectedChoiceId, "notify-on-completed");
1515
- assert.equal(notificationEventCalls[1]?.selectedChoiceId, "notify-on-failed");
1566
+ assert.deepEqual(getTerminalRows("Enable sound", "high"), ["Reset defaults"]);
1567
+ assert.deepEqual(getTerminalRows("Pushover priority", "High"), ["Reset defaults"]);
1568
+ assert.deepEqual(getTerminalRows("Enable tools", "static-check"), ["Reset defaults"]);
1569
+ assert.deepEqual(getTerminalRows("Debug", "Log on status"), ["Reset defaults"]);
1570
+ assert.deepEqual(getTerminalRows("static-check language", "Python"), ["Reset defaults"]);
1571
+ assert.deepEqual(getTerminalRows("Remove static code checker"), ["Reset defaults"]);
1572
+ assert.deepEqual(getTerminalRows("Remove source-code directory", "src"), ["Reset defaults"]);
1516
1573
  });
1517
1574
 
1518
- test("notifications reset defaults preserves non-notification settings", async () => {
1519
- const cwd = createTempDir("pi-usereq-menu-reset-notifications-");
1575
+ test("debug menu dims locked rows and persists debug settings with focus-preserving re-renders", async () => {
1576
+ const cwd = createTempDir("pi-usereq-menu-debug-");
1520
1577
  fs.mkdirSync(path.dirname(getProjectConfigPath(cwd)), { recursive: true });
1521
1578
  const pi = createFakePi();
1522
1579
  piUsereqExtension(pi);
@@ -1524,104 +1581,2313 @@ test("notifications reset defaults preserves non-notification settings", async (
1524
1581
  assert.ok(command);
1525
1582
  const ctx = createFakeCtx(cwd, {
1526
1583
  selects: [
1527
- "docs-dir",
1528
- "notifications",
1529
- "Enable notification",
1530
- "Enable sound",
1531
- "high",
1532
- "Reset defaults",
1584
+ "Debug",
1585
+ "Debug",
1586
+ "Log file",
1587
+ "Log on status",
1588
+ "any",
1589
+ "Status changes",
1590
+ "Workflow events",
1591
+ "files-tokens",
1592
+ "req-analyze",
1533
1593
  "Save and close",
1534
1594
  "Save and close",
1535
1595
  ],
1536
- inputs: ["docs/custom"],
1596
+ inputs: ["logs/debug-output.json"],
1597
+ });
1598
+
1599
+ await command!.handler("", ctx);
1600
+
1601
+ const renderedInitialDebugMenu = (ctx.__state.customRenderLines[1] ?? []).join("\n");
1602
+ const persistedConfig = JSON.parse(fs.readFileSync(getProjectConfigPath(cwd), "utf8"));
1603
+ const debugCalls = ctx.__state.selectCalls.filter((call) => call.title === "Debug");
1604
+ assert.match(renderedInitialDebugMenu, /<dim>Log file/);
1605
+ assert.match(renderedInitialDebugMenu, /<dim>Log on status/);
1606
+ assert.match(renderedInitialDebugMenu, /<dim>Status changes/);
1607
+ assert.match(renderedInitialDebugMenu, /<dim>Workflow events/);
1608
+ assert.match(renderedInitialDebugMenu, /<dim>compress/);
1609
+ assert.equal(persistedConfig.DEBUG_ENABLED, "enable");
1610
+ assert.equal(persistedConfig.DEBUG_LOG_FILE, "logs/debug-output.json");
1611
+ assert.equal(persistedConfig.DEBUG_STATUS_CHANGES, "enable");
1612
+ assert.equal(persistedConfig.DEBUG_WORKFLOW_EVENTS, "enable");
1613
+ assert.equal(persistedConfig.DEBUG_LOG_ON_STATUS, "any");
1614
+ assert.deepEqual(persistedConfig.DEBUG_ENABLED_TOOLS, ["files-tokens"]);
1615
+ assert.deepEqual(persistedConfig.DEBUG_ENABLED_PROMPTS, ["req-analyze"]);
1616
+ assert.equal(debugCalls[1]?.selectedChoiceId, "debug-enabled");
1617
+ assert.equal(debugCalls[2]?.selectedChoiceId, "debug-log-file");
1618
+ assert.equal(debugCalls[3]?.selectedChoiceId, "debug-log-on-status");
1619
+ assert.equal(debugCalls[4]?.selectedChoiceId, "debug-status-changes");
1620
+ assert.equal(debugCalls[5]?.selectedChoiceId, "debug-workflow-events");
1621
+ assert.equal(debugCalls[6]?.selectedChoiceId, "debug-tool:files-tokens");
1622
+ assert.equal(debugCalls[7]?.selectedChoiceId, "debug-prompt:req-analyze");
1623
+
1624
+ const resetCtx = createFakeCtx(cwd, {
1625
+ selects: ["Debug", "Reset defaults", "Approve reset", "Save and close"],
1537
1626
  });
1627
+ await command!.handler("", resetCtx);
1628
+ const resetConfig = JSON.parse(fs.readFileSync(getProjectConfigPath(cwd), "utf8"));
1629
+ assert.equal(resetConfig.DEBUG_ENABLED, "disable");
1630
+ assert.equal(resetConfig.DEBUG_LOG_FILE, DEFAULT_DEBUG_LOG_FILE);
1631
+ assert.equal(resetConfig.DEBUG_STATUS_CHANGES, "disable");
1632
+ assert.equal(resetConfig.DEBUG_WORKFLOW_EVENTS, DEFAULT_DEBUG_WORKFLOW_EVENTS);
1633
+ assert.equal(resetConfig.DEBUG_LOG_ON_STATUS, DEFAULT_DEBUG_LOG_ON_STATUS);
1634
+ assert.deepEqual(resetConfig.DEBUG_ENABLED_TOOLS, []);
1635
+ assert.deepEqual(resetConfig.DEBUG_ENABLED_PROMPTS, []);
1636
+ });
1637
+
1638
+ test("debug menu rows derive from canonical tool and prompt inventories", async () => {
1639
+ const cwd = createTempDir("pi-usereq-menu-debug-inventory-");
1640
+ fs.mkdirSync(path.dirname(getProjectConfigPath(cwd)), { recursive: true });
1641
+ const pi = createFakePi();
1642
+ piUsereqExtension(pi);
1643
+ const command = pi.commands.get("pi-usereq");
1644
+ assert.ok(command);
1645
+ const ctx = createFakeCtx(cwd, {
1646
+ selects: ["Debug", "Save and close"]
1647
+ });
1648
+
1649
+ await command!.handler("", ctx);
1650
+
1651
+ assert.deepEqual(ctx.__state.selectCalls[1]?.items ?? [], [
1652
+ "Debug",
1653
+ "Log file",
1654
+ "Log on status",
1655
+ "Status changes",
1656
+ "Workflow events",
1657
+ ...[
1658
+ ...PI_USEREQ_CUSTOM_TOOL_NAMES,
1659
+ ...PI_USEREQ_EMBEDDED_TOOL_NAMES,
1660
+ ].sort(comparePiUsereqStartupToolNames),
1661
+ ...DEBUG_PROMPT_NAMES,
1662
+ "Reset defaults",
1663
+ ]);
1664
+ assert.deepEqual(
1665
+ DEBUG_PROMPT_NAMES,
1666
+ PROMPT_COMMAND_NAMES.map((promptName) => `req-${promptName}`),
1667
+ );
1668
+ });
1669
+
1670
+ test("tool debug logging honors global enablement and workflow-status filters", async () => {
1671
+ const idleBase = createTempDir("pi-usereq-tool-debug-idle-");
1672
+ fs.mkdirSync(path.dirname(getProjectConfigPath(idleBase)), { recursive: true });
1673
+ fs.writeFileSync(
1674
+ getProjectConfigPath(idleBase),
1675
+ `${JSON.stringify({
1676
+ ...getDefaultConfig(idleBase),
1677
+ DEBUG_ENABLED: "enable",
1678
+ DEBUG_LOG_FILE: "tool-debug.json",
1679
+ DEBUG_LOG_ON_STATUS: "any",
1680
+ DEBUG_ENABLED_TOOLS: ["bash"],
1681
+ }, null, 2)}\n`,
1682
+ "utf8",
1683
+ );
1684
+ const idlePi = createFakePi();
1685
+ piUsereqExtension(idlePi);
1686
+ const idleCtx = createFakeCtx(idleBase);
1687
+ await idlePi.emit("session_start", { reason: "startup" }, idleCtx);
1688
+ await idlePi.emit("tool_result", {
1689
+ toolName: "bash",
1690
+ input: { command: "echo idle" },
1691
+ content: [{ type: "text", text: "idle" }],
1692
+ details: { stdout: "idle" },
1693
+ isError: false,
1694
+ }, idleCtx);
1695
+ const idleLog = JSON.parse(fs.readFileSync(path.join(idleBase, "tool-debug.json"), "utf8"));
1696
+ assert.equal(idleLog.length, 1);
1697
+ assert.equal(idleLog[0].category, "tool");
1698
+ assert.equal(idleLog[0].name, "bash");
1699
+ assert.equal(idleLog[0].workflow_state, "idle");
1700
+ assert.deepEqual(idleLog[0].input, { command: "echo idle" });
1701
+ assert.deepEqual(idleLog[0].result, {
1702
+ content: [{ type: "text", text: "idle" }],
1703
+ details: { stdout: "idle" },
1704
+ });
1705
+ assert.equal(idleLog[0].is_error, false);
1706
+
1707
+ const runningFixture = initFixtureRepo();
1708
+ writeProjectConfigOverrides(runningFixture.projectBase, {
1709
+ GIT_WORKTREE_ENABLED: "disable",
1710
+ DEBUG_ENABLED: "enable",
1711
+ DEBUG_LOG_FILE: "tool-debug.json",
1712
+ DEBUG_LOG_ON_STATUS: "running",
1713
+ DEBUG_ENABLED_TOOLS: ["files-tokens"],
1714
+ });
1715
+ const runningPi = createFakePi();
1716
+ piUsereqExtension(runningPi);
1717
+ const runningCtx = createFakeCtx(runningFixture.projectBase);
1718
+ await runningPi.emit("session_start", { reason: "startup" }, runningCtx);
1719
+ await runningPi.emit("tool_result", {
1720
+ toolName: "files-tokens",
1721
+ input: { files: ["src/fixture_python.py"] },
1722
+ content: [{ type: "text", text: "idle" }],
1723
+ details: { summary: { counted_file_count: 1 } },
1724
+ isError: false,
1725
+ }, runningCtx);
1726
+ assert.equal(fs.existsSync(path.join(runningFixture.projectBase, "tool-debug.json")), false);
1727
+ const runningCommand = runningPi.commands.get("req-implement");
1728
+ assert.ok(runningCommand);
1729
+ await runningCommand!.handler("debug tools", runningCtx);
1730
+ await runningPi.emit("tool_result", {
1731
+ toolName: "files-tokens",
1732
+ input: { files: ["src/fixture_python.py"] },
1733
+ content: [{ type: "text", text: "running" }],
1734
+ details: { summary: { counted_file_count: 1 } },
1735
+ isError: false,
1736
+ }, runningCtx);
1737
+ const runningLog = JSON.parse(fs.readFileSync(path.join(runningFixture.projectBase, "tool-debug.json"), "utf8"));
1738
+ assert.equal(runningLog.length, 1);
1739
+ assert.equal(runningLog[0].workflow_state, "running");
1740
+ assert.equal(runningLog[0].name, "files-tokens");
1741
+
1742
+ const disabledBase = createTempDir("pi-usereq-tool-debug-disabled-");
1743
+ fs.mkdirSync(path.dirname(getProjectConfigPath(disabledBase)), { recursive: true });
1744
+ fs.writeFileSync(
1745
+ getProjectConfigPath(disabledBase),
1746
+ `${JSON.stringify({
1747
+ ...getDefaultConfig(disabledBase),
1748
+ DEBUG_ENABLED: "disable",
1749
+ DEBUG_LOG_FILE: "tool-debug.json",
1750
+ DEBUG_LOG_ON_STATUS: "any",
1751
+ DEBUG_ENABLED_TOOLS: ["bash"],
1752
+ }, null, 2)}\n`,
1753
+ "utf8",
1754
+ );
1755
+ const disabledPi = createFakePi();
1756
+ piUsereqExtension(disabledPi);
1757
+ const disabledCtx = createFakeCtx(disabledBase);
1758
+ await disabledPi.emit("session_start", { reason: "startup" }, disabledCtx);
1759
+ await disabledPi.emit("tool_result", {
1760
+ toolName: "bash",
1761
+ input: { command: "echo disabled" },
1762
+ content: [{ type: "text", text: "disabled" }],
1763
+ details: { stdout: "disabled" },
1764
+ isError: false,
1765
+ }, disabledCtx);
1766
+ assert.equal(fs.existsSync(path.join(disabledBase, "tool-debug.json")), false);
1767
+ });
1768
+
1769
+ test("prompt debug logging captures failing and successful prompt orchestration entries", async () => {
1770
+ const failingFixture = initFixtureRepo();
1771
+ fs.rmSync(path.join(failingFixture.projectBase, DEFAULT_DOCS_DIR, "WORKFLOW.md"), { force: true });
1772
+ assert.equal(spawnSync("git", ["add", "-A"], { cwd: failingFixture.projectBase, encoding: "utf8" }).status, 0);
1773
+ assert.equal(
1774
+ spawnSync("git", ["commit", "-m", "remove workflow"], { cwd: failingFixture.projectBase, encoding: "utf8" }).status,
1775
+ 0,
1776
+ );
1777
+ writeProjectConfigOverrides(failingFixture.projectBase, {
1778
+ DEBUG_ENABLED: "enable",
1779
+ DEBUG_LOG_FILE: "prompt-debug.json",
1780
+ DEBUG_STATUS_CHANGES: "enable",
1781
+ DEBUG_WORKFLOW_EVENTS: "enable",
1782
+ DEBUG_LOG_ON_STATUS: "any",
1783
+ DEBUG_ENABLED_PROMPTS: ["req-change"],
1784
+ });
1785
+ const failingPi = createFakePi();
1786
+ piUsereqExtension(failingPi);
1787
+ const failingCtx = createFakeCtx(failingFixture.projectBase);
1788
+ await failingPi.emit("session_start", { reason: "startup" }, failingCtx);
1789
+ const failingCommand = failingPi.commands.get("req-change");
1790
+ assert.ok(failingCommand);
1791
+ await assert.rejects(async () => {
1792
+ await failingCommand!.handler("debug prompt", failingCtx);
1793
+ }, /Git status unclear|WORKFLOW\.md/);
1794
+ const failingLog = JSON.parse(fs.readFileSync(path.join(failingFixture.projectBase, "prompt-debug.json"), "utf8"));
1795
+ assert.ok(failingLog.some((entry: any) => entry.action === "workflow_state" && entry.result?.workflow_state === "error"));
1796
+
1797
+ const successFixture = initFixtureRepo();
1798
+ writeProjectConfigOverrides(successFixture.projectBase, {
1799
+ DEBUG_ENABLED: "enable",
1800
+ DEBUG_LOG_FILE: "prompt-debug.json",
1801
+ DEBUG_STATUS_CHANGES: "enable",
1802
+ DEBUG_WORKFLOW_EVENTS: "enable",
1803
+ DEBUG_LOG_ON_STATUS: "any",
1804
+ DEBUG_ENABLED_PROMPTS: ["req-implement"],
1805
+ });
1806
+ const successPi = createFakePi();
1807
+ piUsereqExtension(successPi);
1808
+ const successCtx = createFakeCtx(successFixture.projectBase);
1809
+ await successPi.emit("session_start", { reason: "startup" }, successCtx);
1810
+ const successCommand = successPi.commands.get("req-implement");
1811
+ assert.ok(successCommand);
1812
+ await successCommand!.handler("debug prompt", successCtx);
1813
+ const promptText = String(successPi.sentUserMessages.at(-1)?.content ?? "");
1814
+ const worktreeMatch = promptText.match(/created worktree-dir `([^`]+)` and prepared context-path `([^`]+)`\./);
1815
+ assert.ok(worktreeMatch, promptText);
1816
+ const executionBasePath = worktreeMatch?.[2] ?? "";
1817
+ await successPi.emit("before_agent_start", {}, successCtx);
1818
+ await successPi.emit("agent_start", {}, successCtx);
1819
+ fs.writeFileSync(path.join(executionBasePath, "src", "debug.ts"), "export const debug = true;\n", "utf8");
1820
+ assert.equal(spawnSync("git", ["add", "src/debug.ts"], { cwd: executionBasePath, encoding: "utf8" }).status, 0);
1821
+ const worktreeCommit = spawnSync("git", ["commit", "-m", "debug prompt"], {
1822
+ cwd: executionBasePath,
1823
+ encoding: "utf8",
1824
+ });
1825
+ assert.equal(worktreeCommit.status, 0, worktreeCommit.stderr);
1826
+ await successPi.emit("agent_end", {
1827
+ messages: [{ role: "assistant", stopReason: "end_turn" }],
1828
+ }, successCtx);
1829
+ const successLog = JSON.parse(fs.readFileSync(path.join(successFixture.projectBase, "prompt-debug.json"), "utf8"));
1830
+ assert.ok(successLog.some((entry: any) => entry.action === "required_docs_check" && entry.result?.success === true));
1831
+ assert.ok(successLog.some((entry: any) => entry.action === "worktree_create" && entry.result?.success === true));
1832
+ assert.ok(successLog.some((entry: any) => entry.action === "workflow_activation" && entry.result?.success === true));
1833
+ assert.ok(successLog.some((entry: any) => entry.action === "workflow_closure" && entry.result?.should_finalize_matched_success === true));
1834
+ assert.ok(successLog.some((entry: any) => entry.action === "workflow_restore" && entry.result?.success === true));
1835
+ assert.ok(successLog.some((entry: any) => entry.action === "merge" && entry.result?.success === true));
1836
+ assert.ok(successLog.some((entry: any) => entry.action === "worktree_delete" && entry.result?.success === true));
1837
+ assert.ok(successLog.some((entry: any) => entry.action === "workflow_state" && entry.result?.workflow_state === "merging"));
1838
+ assert.ok(successLog.some((entry: any) => entry.action === "workflow_state" && entry.result?.workflow_state === "idle"));
1839
+ });
1840
+
1841
+ test("notifications menu preserves focus on toggled and edited rows", async () => {
1842
+ const cwd = createTempDir("pi-usereq-menu-focus-");
1843
+ fs.mkdirSync(path.dirname(getProjectConfigPath(cwd)), { recursive: true });
1844
+ const pi = createFakePi();
1845
+ piUsereqExtension(pi);
1846
+ const command = pi.commands.get("pi-usereq");
1847
+ assert.ok(command);
1848
+ const ctx = createFakeCtx(cwd, {
1849
+ selects: [
1850
+ "notifications",
1851
+ "Notification events",
1852
+ "Prompt failed",
1853
+ "Save and close",
1854
+ "Notify command",
1855
+ "Save and close",
1856
+ "Save and close",
1857
+ ],
1858
+ inputs: ["echo notify"],
1859
+ });
1860
+
1861
+ await command!.handler("", ctx);
1862
+
1863
+ const notificationCalls = ctx.__state.selectCalls.filter((call) => call.title === "Notifications");
1864
+ assert.equal(notificationCalls[0]?.selectedChoiceId, "notify-enabled");
1865
+ assert.equal(notificationCalls[1]?.selectedChoiceId, "notification-events");
1866
+ assert.equal(notificationCalls[2]?.selectedChoiceId, "notify-command");
1867
+
1868
+ const notificationEventCalls = ctx.__state.selectCalls.filter((call) => call.title === "Notification events");
1869
+ assert.deepEqual(notificationEventCalls[0]?.items ?? [], [
1870
+ "Prompt completed",
1871
+ "Prompt interrupted",
1872
+ "Prompt failed",
1873
+ "Reset defaults",
1874
+ ]);
1875
+ assert.equal(notificationEventCalls[0]?.selectedChoiceId, "notify-on-completed");
1876
+ assert.equal(notificationEventCalls[1]?.selectedChoiceId, "notify-on-failed");
1877
+ });
1878
+
1879
+ test("notifications reset defaults preserves non-notification settings", async () => {
1880
+ const cwd = createTempDir("pi-usereq-menu-reset-notifications-");
1881
+ fs.mkdirSync(path.dirname(getProjectConfigPath(cwd)), { recursive: true });
1882
+ const pi = createFakePi();
1883
+ piUsereqExtension(pi);
1884
+ const command = pi.commands.get("pi-usereq");
1885
+ assert.ok(command);
1886
+ const ctx = createFakeCtx(cwd, {
1887
+ selects: [
1888
+ "docs-dir",
1889
+ "notifications",
1890
+ "Enable notification",
1891
+ "Enable sound",
1892
+ "high",
1893
+ "Reset defaults",
1894
+ "Approve reset",
1895
+ "Save and close",
1896
+ "Save and close",
1897
+ ],
1898
+ inputs: ["docs/custom"],
1899
+ });
1900
+
1901
+ await command!.handler("", ctx);
1902
+
1903
+ const config = JSON.parse(fs.readFileSync(getProjectConfigPath(cwd), "utf8"));
1904
+ assert.equal(config["docs-dir"], "docs/custom");
1905
+ assert.equal(config["notify-enabled"], false);
1906
+ assert.equal(config["notify-sound"], "none");
1907
+ assert.equal(config["notify-pushover-enabled"], false);
1908
+ });
1909
+
1910
+ test("top-level reset defaults restores all configuration values", async () => {
1911
+ const cwd = createTempDir("pi-usereq-menu-reset-all-");
1912
+ fs.mkdirSync(path.dirname(getProjectConfigPath(cwd)), { recursive: true });
1913
+ const pi = createFakePi();
1914
+ piUsereqExtension(pi);
1915
+ const command = pi.commands.get("pi-usereq");
1916
+ assert.ok(command);
1917
+ const ctx = createFakeCtx(cwd, {
1918
+ selects: [
1919
+ "docs-dir",
1920
+ "notifications",
1921
+ "Enable notification",
1922
+ "Save and close",
1923
+ "Reset defaults",
1924
+ "Approve reset",
1925
+ "Save and close",
1926
+ ],
1927
+ inputs: ["docs/custom"],
1928
+ });
1929
+
1930
+ await command!.handler("", ctx);
1931
+
1932
+ const config = JSON.parse(fs.readFileSync(getProjectConfigPath(cwd), "utf8"));
1933
+ assert.equal(config["docs-dir"], DEFAULT_DOCS_DIR);
1934
+ assert.deepEqual(config["src-dir"], ["src"]);
1935
+ assert.equal(config["notify-enabled"], false);
1936
+ assert.equal(config["notify-sound"], "none");
1937
+ });
1938
+
1939
+ test("configuration menus reuse CLI settings theme semantics", async () => {
1940
+ const cwd = createTempDir("pi-usereq-menu-theme-");
1941
+ fs.mkdirSync(path.dirname(getProjectConfigPath(cwd)), { recursive: true });
1942
+ const usedColors = new Set<string>();
1943
+ const pi = createFakePi();
1944
+ piUsereqExtension(pi);
1945
+ const command = pi.commands.get("pi-usereq");
1946
+ assert.ok(command);
1947
+ const ctx = createFakeCtx(cwd, { selects: [] }, {
1948
+ theme: {
1949
+ fg(color: string, text: string) {
1950
+ usedColors.add(color);
1951
+ return formatFakeThemeForeground(color, text);
1952
+ },
1953
+ bgFromFg(color: string, text: string) {
1954
+ assert.ok(["accent", "muted", "dim"].includes(color));
1955
+ return formatFakeThemeBackgroundFromForeground(color, text);
1956
+ },
1957
+ bold(text: string) {
1958
+ return `<bold>${text}</bold>`;
1959
+ },
1960
+ },
1961
+ });
1962
+
1963
+ await command!.handler("", ctx);
1964
+
1965
+ const renderedMenu = (ctx.__state.customRenderLines[0] ?? []).join("\n");
1966
+ assert.match(renderedMenu, /<accent><bold>pi-usereq<\/bold><\/accent>/);
1967
+ assert.match(renderedMenu, /<accent>→ <\/accent>/);
1968
+ assert.match(renderedMenu, /<accent>pi-usereq\/docs<\/accent>/);
1969
+ assert.match(renderedMenu, /<muted>tests<\/muted>/);
1970
+ assert.match(renderedMenu, /<dim>/);
1971
+ assert.deepEqual([...usedColors].sort(), ["accent", "dim", "muted"]);
1972
+ });
1973
+
1974
+ test("prompt commands use the current session by default", async () => {
1975
+ const { projectBase } = initFixtureRepo({ fixtures: [] });
1976
+ try {
1977
+ writeProjectConfigOverrides(projectBase, { GIT_WORKTREE_ENABLED: "disable" });
1978
+ const pi = createFakePi();
1979
+ piUsereqExtension(pi);
1980
+ const command = pi.commands.get("req-analyze");
1981
+ assert.ok(command);
1982
+ const ctx = createFakeCtx(projectBase);
1983
+
1984
+ await command!.handler("Inspect src/index.ts for prompt coverage", ctx);
1985
+
1986
+ assert.equal(ctx.__state.waitForIdleCalls, 0);
1987
+ assert.equal(ctx.__state.newSessions.length, 0);
1988
+ assert.equal(pi.sentUserMessages.length, 1);
1989
+ assert.match(String(pi.sentUserMessages[0]?.content), /Inspect src\/index\.ts for prompt coverage/);
1990
+ } finally {
1991
+ fs.rmSync(projectBase, { recursive: true, force: true });
1992
+ }
1993
+ });
1994
+
1995
+ test("prompt commands skip worktree creation when auto git commit is disabled", async () => {
1996
+ const { projectBase } = initFixtureRepo({ fixtures: [] });
1997
+ try {
1998
+ writeProjectConfigOverrides(projectBase, {
1999
+ AUTO_GIT_COMMIT: "disable",
2000
+ GIT_WORKTREE_ENABLED: "enable",
2001
+ });
2002
+ const pi = createFakePi();
2003
+ piUsereqExtension(pi);
2004
+ const command = pi.commands.get("req-change");
2005
+ assert.ok(command);
2006
+ const ctx = createFakeCtx(projectBase);
2007
+
2008
+ await command!.handler("Adjust docs", ctx);
2009
+
2010
+ const promptText = String(pi.sentUserMessages[0]?.content ?? "");
2011
+ const expectedBasePath = buildExpectedFakeBasePath(projectBase).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
2012
+ assert.doesNotMatch(promptText, /created worktree/);
2013
+ assert.match(
2014
+ promptText,
2015
+ new RegExp(`worktree orchestration is disabled and context-path remains \`${expectedBasePath}\``),
2016
+ );
2017
+ } finally {
2018
+ fs.rmSync(projectBase, { recursive: true, force: true });
2019
+ }
2020
+ });
2021
+
2022
+ test("prompt commands capture checking before handoff and running at prompt injection", async () => {
2023
+ const { projectBase } = initFixtureRepo({ fixtures: [] });
2024
+ const previousCwd = process.cwd();
2025
+ let checkingStatus = "";
2026
+ let handoffStatus = "";
2027
+ try {
2028
+ const pi = createFakePi();
2029
+ piUsereqExtension(pi);
2030
+ const ctx = createFakeCtx(projectBase);
2031
+ await pi.emit("session_start", { reason: "startup" }, ctx);
2032
+ const originalSendUserMessage = pi.sendUserMessage.bind(pi);
2033
+ pi.sendUserMessage = (content: unknown, options?: unknown) => {
2034
+ handoffStatus = ctx.__state.statuses.get("pi-usereq") ?? "";
2035
+ originalSendUserMessage(content, options);
2036
+ };
2037
+ setPromptCommandPostCreateHookForTests(() => {
2038
+ checkingStatus = ctx.__state.statuses.get("pi-usereq") ?? "";
2039
+ });
2040
+
2041
+ await pi.commands.get("req-change")!.handler("Adjust docs", ctx);
2042
+
2043
+ const promptText = String(pi.sentUserMessages[0]?.content ?? "");
2044
+ const worktreeMatch = promptText.match(/created worktree-dir `([^`]+)` and prepared context-path `([^`]+)`\./);
2045
+ assert.ok(worktreeMatch, promptText);
2046
+ const executionBasePath = worktreeMatch?.[2] ?? "";
2047
+ assert.equal(
2048
+ checkingStatus,
2049
+ buildExpectedFakeStatusText({
2050
+ workflowState: "checking",
2051
+ basePath: buildExpectedFakeBasePath(projectBase),
2052
+ docsDir: DEFAULT_DOCS_DIR,
2053
+ testsDir: "tests",
2054
+ srcDir: ["src"],
2055
+ contextFilledCells: 0,
2056
+ et: "⏱︎ --:-- ⚑ --:-- ⌛︎--:--",
2057
+ }),
2058
+ );
2059
+ assert.equal(
2060
+ handoffStatus,
2061
+ buildExpectedFakeStatusText({
2062
+ workflowState: "checking",
2063
+ basePath: buildExpectedFakeBasePath(executionBasePath),
2064
+ docsDir: DEFAULT_DOCS_DIR,
2065
+ testsDir: "tests",
2066
+ srcDir: ["src"],
2067
+ contextFilledCells: 0,
2068
+ et: "⏱︎ --:-- ⚑ --:-- ⌛︎--:--",
2069
+ }),
2070
+ );
2071
+ assert.equal(
2072
+ ctx.__state.statuses.get("pi-usereq"),
2073
+ buildExpectedFakeStatusText({
2074
+ workflowState: "running",
2075
+ basePath: buildExpectedFakeBasePath(executionBasePath),
2076
+ docsDir: DEFAULT_DOCS_DIR,
2077
+ testsDir: "tests",
2078
+ srcDir: ["src"],
2079
+ contextFilledCells: 0,
2080
+ et: "⏱︎ --:-- ⚑ --:-- ⌛︎--:--",
2081
+ }),
2082
+ );
2083
+
2084
+ await pi.emit("before_agent_start", {}, ctx);
2085
+ await pi.emit("agent_start", {}, ctx);
2086
+ await pi.emit("agent_end", {
2087
+ messages: [{ role: "assistant", stopReason: "stop", content: [] }],
2088
+ }, ctx);
2089
+ } finally {
2090
+ setPromptCommandPostCreateHookForTests(undefined);
2091
+ process.chdir(previousCwd);
2092
+ fs.rmSync(projectBase, { recursive: true, force: true });
2093
+ }
2094
+ });
2095
+
2096
+ test("prompt commands reject non-idle workflow state before starting a new orchestration", async () => {
2097
+ const { projectBase } = initFixtureRepo({ fixtures: [] });
2098
+ try {
2099
+ writeProjectConfigOverrides(projectBase, { GIT_WORKTREE_ENABLED: "disable" });
2100
+ const pi = createFakePi();
2101
+ piUsereqExtension(pi);
2102
+ const ctx = createFakeCtx(projectBase);
2103
+ await pi.emit("session_start", { reason: "startup" }, ctx);
2104
+
2105
+ await pi.commands.get("req-change")!.handler("Adjust docs", ctx);
2106
+ assert.equal(
2107
+ ctx.__state.statuses.get("pi-usereq"),
2108
+ buildExpectedFakeStatusText({
2109
+ workflowState: "running",
2110
+ basePath: buildExpectedFakeBasePath(projectBase),
2111
+ docsDir: DEFAULT_DOCS_DIR,
2112
+ testsDir: "tests",
2113
+ srcDir: ["src"],
2114
+ contextFilledCells: 0,
2115
+ et: "⏱︎ --:-- ⚑ --:-- ⌛︎--:--",
2116
+ }),
2117
+ );
2118
+
2119
+ await assert.rejects(
2120
+ pi.commands.get("req-analyze")!.handler("Inspect src/index.ts", ctx),
2121
+ /expected idle/,
2122
+ );
2123
+ assert.equal(pi.sentUserMessages.length, 1);
2124
+ assert.ok(ctx.__state.notifications.some((entry) => /expected idle/.test(entry.message)));
2125
+ assert.equal(
2126
+ ctx.__state.statuses.get("pi-usereq"),
2127
+ buildExpectedFakeStatusText({
2128
+ workflowState: "running",
2129
+ basePath: buildExpectedFakeBasePath(projectBase),
2130
+ docsDir: DEFAULT_DOCS_DIR,
2131
+ testsDir: "tests",
2132
+ srcDir: ["src"],
2133
+ contextFilledCells: 0,
2134
+ et: "⏱︎ --:-- ⚑ --:-- ⌛︎--:--",
2135
+ }),
2136
+ );
2137
+
2138
+ await pi.emit("agent_start", {}, ctx);
2139
+ await pi.emit("agent_end", {
2140
+ messages: [{ role: "assistant", stopReason: "stop", content: [] }],
2141
+ }, ctx);
2142
+ assert.equal(
2143
+ ctx.__state.statuses.get("pi-usereq"),
2144
+ buildExpectedFakeStatusText({
2145
+ workflowState: "idle",
2146
+ basePath: buildExpectedFakeBasePath(projectBase),
2147
+ docsDir: DEFAULT_DOCS_DIR,
2148
+ testsDir: "tests",
2149
+ srcDir: ["src"],
2150
+ contextFilledCells: 0,
2151
+ et: "⏱︎ --:-- ⚑ 0:00 ⌛︎0:00",
2152
+ }),
2153
+ );
2154
+ } finally {
2155
+ fs.rmSync(projectBase, { recursive: true, force: true });
2156
+ }
2157
+ });
2158
+
2159
+ test("prompt commands abort when git validation fails", async () => {
2160
+ const { projectBase } = initFixtureRepo({ fixtures: [] });
2161
+ const previousCwd = process.cwd();
2162
+ try {
2163
+ fs.writeFileSync(path.join(projectBase, "DIRTY.txt"), "dirty\n", "utf8");
2164
+ const pi = createFakePi();
2165
+ piUsereqExtension(pi);
2166
+ const ctx = createFakeCtx(projectBase);
2167
+ await pi.emit("session_start", { reason: "startup" }, ctx);
2168
+
2169
+ await assert.rejects(
2170
+ pi.commands.get("req-analyze")!.handler("Inspect src/index.ts", ctx),
2171
+ /ERROR: Git status unclear!/,
2172
+ );
2173
+ assert.equal(pi.sentUserMessages.length, 0);
2174
+ assert.ok(ctx.__state.notifications.some((entry) => /ERROR: Git status unclear!/.test(entry.message)));
2175
+ assert.equal(
2176
+ ctx.__state.statuses.get("pi-usereq"),
2177
+ buildExpectedFakeStatusText({
2178
+ workflowState: "error",
2179
+ basePath: buildExpectedFakeBasePath(projectBase),
2180
+ docsDir: DEFAULT_DOCS_DIR,
2181
+ testsDir: "tests",
2182
+ srcDir: ["src"],
2183
+ contextFilledCells: 0,
2184
+ et: "⏱︎ --:-- ⚑ --:-- ⌛︎--:--",
2185
+ }),
2186
+ );
2187
+ } finally {
2188
+ process.chdir(previousCwd);
2189
+ fs.rmSync(projectBase, { recursive: true, force: true });
2190
+ }
2191
+ });
2192
+
2193
+ test("prompt commands enforce the documented required-doc matrix", async () => {
2194
+ const { projectBase } = initFixtureRepo({
2195
+ fixtures: [],
2196
+ docs: {
2197
+ "REQUIREMENTS.md": "# Requirements\n",
2198
+ },
2199
+ });
2200
+ const previousCwd = process.cwd();
2201
+ try {
2202
+ const worktreeParentPath = path.dirname(projectBase);
2203
+ const initialEntries = new Set(fs.readdirSync(worktreeParentPath));
2204
+ const pi = createFakePi();
2205
+ piUsereqExtension(pi);
2206
+ const ctx = createFakeCtx(projectBase);
2207
+
2208
+ await assert.rejects(
2209
+ pi.commands.get("req-change")!.handler("Adjust docs", ctx),
2210
+ /WORKFLOW\.md/,
2211
+ );
2212
+ const createdWorktreeEntries = fs.readdirSync(worktreeParentPath)
2213
+ .filter((entry) => !initialEntries.has(entry))
2214
+ .filter((entry) => /^PI-useReq-/.test(entry));
2215
+ assert.equal(pi.sentUserMessages.length, 0);
2216
+ assert.deepEqual(createdWorktreeEntries, []);
2217
+ assert.ok(ctx.__state.notifications.some((entry) => /WORKFLOW\.md/.test(entry.message)));
2218
+ } finally {
2219
+ process.chdir(previousCwd);
2220
+ fs.rmSync(projectBase, { recursive: true, force: true });
2221
+ }
2222
+ });
2223
+
2224
+ test("worktree-enabled prompt commands merge successful runs, restore base-path, and delete the worktree", async () => {
2225
+ const { projectBase } = initFixtureRepo({ fixtures: [] });
2226
+ const previousCwd = process.cwd();
2227
+ try {
2228
+ const pi = createFakePi();
2229
+ piUsereqExtension(pi);
2230
+ const ctx = createFakeCtx(projectBase);
2231
+ const recordedStatuses: string[] = [];
2232
+ const originalSetStatus = ctx.ui.setStatus.bind(ctx.ui);
2233
+ ctx.ui.setStatus = (key: string, value?: string) => {
2234
+ if (key === "pi-usereq" && value !== undefined) {
2235
+ recordedStatuses.push(value);
2236
+ }
2237
+ originalSetStatus(key, value);
2238
+ };
2239
+ await pi.emit("session_start", { reason: "startup" }, ctx);
2240
+
2241
+ await pi.commands.get("req-change")!.handler("Adjust docs", ctx);
2242
+ const promptText = String(pi.sentUserMessages[0]?.content ?? "");
2243
+ const worktreeMatch = promptText.match(/created worktree-dir `([^`]+)` and prepared context-path `([^`]+)`\./);
2244
+ assert.ok(worktreeMatch, promptText);
2245
+ const worktreeName = worktreeMatch?.[1] ?? "";
2246
+ const executionBasePath = worktreeMatch?.[2] ?? "";
2247
+ assert.match(worktreeName, /^PI-useReq-/);
2248
+ assert.ok(fs.existsSync(executionBasePath));
2249
+ assert.equal(
2250
+ spawnSync("git", ["show-ref", "--verify", "--quiet", `refs/heads/${worktreeName}`], {
2251
+ cwd: projectBase,
2252
+ encoding: "utf8",
2253
+ }).status,
2254
+ 0,
2255
+ );
2256
+ assert.equal(process.cwd(), executionBasePath);
2257
+ assert.equal(ctx.cwd, executionBasePath);
2258
+ assert.ok(recordedStatuses.some((status) => /<accent>status:<\/accent><warning>running<\/warning>/.test(status)));
2259
+
2260
+ await pi.emit("before_agent_start", {}, ctx);
2261
+ assert.equal(process.cwd(), executionBasePath);
2262
+ const filesTokensTool = pi.tools.find((candidate: RegisteredTool) => candidate.name === "files-tokens");
2263
+ assert.ok(filesTokensTool?.execute, "missing files-tokens execute handler");
2264
+ fs.mkdirSync(path.join(executionBasePath, "src"), { recursive: true });
2265
+ fs.writeFileSync(path.join(executionBasePath, "src", "tool-relative.ts"), "export const TOOL_RELATIVE = 1;\n", "utf8");
2266
+ const toolResult = await filesTokensTool.execute!("tool-call-id", { files: ["src/tool-relative.ts"] }, undefined, undefined, ctx) as {
2267
+ content?: Array<{ type?: string; text?: string }>;
2268
+ };
2269
+ assert.match(toolResult.content?.[0]?.text ?? "", /tool-relative\.ts/);
2270
+ await pi.emit("agent_start", {}, ctx);
2271
+ fs.writeFileSync(path.join(executionBasePath, "src", "orchestrated.ts"), "export const ORCHESTRATED = 1;\n", "utf8");
2272
+ assert.equal(spawnSync("git", ["add", "src/orchestrated.ts"], { cwd: executionBasePath, encoding: "utf8" }).status, 0);
2273
+ const commit = spawnSync("git", ["commit", "-m", "worktree change"], { cwd: executionBasePath, encoding: "utf8" });
2274
+ assert.equal(commit.status, 0, commit.stderr);
2275
+
2276
+ await pi.emit("agent_end", {
2277
+ messages: [{ role: "assistant", stopReason: "stop", content: [] }],
2278
+ }, ctx);
2279
+
2280
+ assert.equal(process.cwd(), projectBase);
2281
+ assert.equal(ctx.cwd, projectBase);
2282
+ assert.ok(fs.existsSync(path.join(projectBase, "src", "orchestrated.ts")));
2283
+ assert.equal(fs.existsSync(executionBasePath), false);
2284
+ assert.notEqual(
2285
+ spawnSync("git", ["show-ref", "--verify", "--quiet", `refs/heads/${worktreeName}`], {
2286
+ cwd: projectBase,
2287
+ encoding: "utf8",
2288
+ }).status,
2289
+ 0,
2290
+ );
2291
+ assert.equal(ctx.__state.notifications.filter((entry) => entry.level === "error").length, 0);
2292
+ assert.ok(recordedStatuses.some((status) => /<accent>status:<\/accent><warning>merging<\/warning>/.test(status)));
2293
+ assert.match(ctx.__state.statuses.get("pi-usereq") ?? "", /<accent>status:<\/accent><warning>idle<\/warning>/);
2294
+ } finally {
2295
+ process.chdir(previousCwd);
2296
+ fs.rmSync(projectBase, { recursive: true, force: true });
2297
+ }
2298
+ });
2299
+
2300
+ /**
2301
+ * @brief Verifies prompt-command bootstrap ignores stale deleted-worktree session metadata on later commands.
2302
+ * @details Simulates the `/tmp/debug.json` follow-up failure mode: one successful worktree-backed `req-change` run restores `base-path` and deletes its worktree, then a later `req-change` invocation receives a stale command context whose `cwd` and session getters still point at the removed execution worktree. The test proves command bootstrap MUST discard deleted-worktree cwd/session residue, prepare a fresh worktree with a new identifier, and complete the second successful run without reusing the removed execution path.
2303
+ * @return {Promise<void>} Promise resolved after two successful prompt-command runs complete.
2304
+ * @throws {AssertionError} Throws when the second command aborts with the canonical git-status preflight error or reuses the deleted worktree context.
2305
+ * @satisfies REQ-200, REQ-206, REQ-208, REQ-219, REQ-257
2306
+ */
2307
+ test("prompt-command bootstrap ignores stale deleted-worktree session metadata on later commands", async () => {
2308
+ const { projectBase } = initFixtureRepo({ fixtures: [] });
2309
+ const previousCwd = process.cwd();
2310
+ try {
2311
+ const pi = createFakePi();
2312
+ piUsereqExtension(pi);
2313
+ const ctx = createFakeCtx(projectBase);
2314
+ await pi.emit("session_start", { reason: "startup" }, ctx);
2315
+
2316
+ await pi.commands.get("req-change")!.handler("Adjust docs", ctx);
2317
+ const firstPromptText = String(pi.sentUserMessages[0]?.content ?? "");
2318
+ const firstWorktreeMatch = firstPromptText.match(/created worktree-dir `([^`]+)` and prepared context-path `([^`]+)`\./);
2319
+ assert.ok(firstWorktreeMatch, firstPromptText);
2320
+ const firstWorktreeName = firstWorktreeMatch?.[1] ?? "";
2321
+ const firstExecutionBasePath = firstWorktreeMatch?.[2] ?? "";
2322
+ const firstExecutionSessionFile = ctx.sessionManager.getSessionFile() ?? "";
2323
+ assert.notEqual(firstExecutionSessionFile, "");
2324
+
2325
+ await pi.emit("before_agent_start", {}, ctx);
2326
+ await pi.emit("agent_start", {}, ctx);
2327
+ fs.mkdirSync(path.join(firstExecutionBasePath, "src"), { recursive: true });
2328
+ fs.writeFileSync(path.join(firstExecutionBasePath, "src", "bootstrap-first.ts"), "export const BOOTSTRAP_FIRST = 1;\n", "utf8");
2329
+ assert.equal(
2330
+ spawnSync("git", ["add", "src/bootstrap-first.ts"], {
2331
+ cwd: firstExecutionBasePath,
2332
+ encoding: "utf8",
2333
+ }).status,
2334
+ 0,
2335
+ );
2336
+ const firstCommit = spawnSync("git", ["commit", "-m", "bootstrap first"], {
2337
+ cwd: firstExecutionBasePath,
2338
+ encoding: "utf8",
2339
+ });
2340
+ assert.equal(firstCommit.status, 0, firstCommit.stderr);
2341
+
2342
+ await pi.emit("agent_end", {
2343
+ messages: [{ role: "assistant", stopReason: "stop", content: [] }],
2344
+ }, ctx);
2345
+
2346
+ assert.equal(process.cwd(), projectBase);
2347
+ assert.equal(ctx.cwd, projectBase);
2348
+ assert.equal(fs.existsSync(firstExecutionBasePath), false);
2349
+ assert.notEqual(
2350
+ spawnSync("git", ["show-ref", "--verify", "--quiet", `refs/heads/${firstWorktreeName}`], {
2351
+ cwd: projectBase,
2352
+ encoding: "utf8",
2353
+ }).status,
2354
+ 0,
2355
+ );
2356
+
2357
+ ctx.cwd = firstExecutionBasePath;
2358
+ ctx.sessionManager.getCwd = () => firstExecutionBasePath;
2359
+ ctx.sessionManager.getSessionFile = () => firstExecutionSessionFile;
2360
+ ctx.sessionManager.getSessionDir = () => path.dirname(firstExecutionSessionFile);
2361
+
2362
+ await pi.commands.get("req-change")!.handler("Adjust docs again", ctx);
2363
+ const secondPromptText = String(pi.sentUserMessages[1]?.content ?? "");
2364
+ const secondWorktreeMatch = secondPromptText.match(/created worktree-dir `([^`]+)` and prepared context-path `([^`]+)`\./);
2365
+ assert.ok(secondWorktreeMatch, secondPromptText);
2366
+ const secondWorktreeName = secondWorktreeMatch?.[1] ?? "";
2367
+ const secondExecutionBasePath = secondWorktreeMatch?.[2] ?? "";
2368
+
2369
+ assert.notEqual(secondWorktreeName, firstWorktreeName);
2370
+ assert.notEqual(secondExecutionBasePath, firstExecutionBasePath);
2371
+ assert.equal(process.cwd(), secondExecutionBasePath);
2372
+ assert.equal(ctx.cwd, secondExecutionBasePath);
2373
+
2374
+ await pi.emit("before_agent_start", {}, ctx);
2375
+ await pi.emit("agent_start", {}, ctx);
2376
+ fs.mkdirSync(path.join(secondExecutionBasePath, "src"), { recursive: true });
2377
+ fs.writeFileSync(path.join(secondExecutionBasePath, "src", "bootstrap-second.ts"), "export const BOOTSTRAP_SECOND = 1;\n", "utf8");
2378
+ assert.equal(
2379
+ spawnSync("git", ["add", "src/bootstrap-second.ts"], {
2380
+ cwd: secondExecutionBasePath,
2381
+ encoding: "utf8",
2382
+ }).status,
2383
+ 0,
2384
+ );
2385
+ const secondCommit = spawnSync("git", ["commit", "-m", "bootstrap second"], {
2386
+ cwd: secondExecutionBasePath,
2387
+ encoding: "utf8",
2388
+ });
2389
+ assert.equal(secondCommit.status, 0, secondCommit.stderr);
2390
+
2391
+ await pi.emit("agent_end", {
2392
+ messages: [{ role: "assistant", stopReason: "stop", content: [] }],
2393
+ }, ctx);
2394
+
2395
+ assert.equal(process.cwd(), projectBase);
2396
+ assert.equal(ctx.cwd, projectBase);
2397
+ assert.equal(fs.existsSync(secondExecutionBasePath), false);
2398
+ assert.ok(fs.existsSync(path.join(projectBase, "src", "bootstrap-first.ts")));
2399
+ assert.ok(fs.existsSync(path.join(projectBase, "src", "bootstrap-second.ts")));
2400
+ assert.notEqual(
2401
+ spawnSync("git", ["show-ref", "--verify", "--quiet", `refs/heads/${secondWorktreeName}`], {
2402
+ cwd: projectBase,
2403
+ encoding: "utf8",
2404
+ }).status,
2405
+ 0,
2406
+ );
2407
+ assert.equal(ctx.__state.notifications.filter((entry) => entry.level === "error").length, 0);
2408
+ } finally {
2409
+ process.chdir(previousCwd);
2410
+ fs.rmSync(projectBase, { recursive: true, force: true });
2411
+ }
2412
+ });
2413
+
2414
+ test("worktree-backed finalization transitions through merging, error, and idle when merge fails", async () => {
2415
+ const { projectBase } = initFixtureRepo({ fixtures: [] });
2416
+ const previousCwd = process.cwd();
2417
+ try {
2418
+ const pi = createFakePi();
2419
+ piUsereqExtension(pi);
2420
+ const ctx = createFakeCtx(projectBase);
2421
+ const recordedStatuses: string[] = [];
2422
+ const originalSetStatus = ctx.ui.setStatus.bind(ctx.ui);
2423
+ ctx.ui.setStatus = (key: string, value?: string) => {
2424
+ if (key === "pi-usereq" && value !== undefined) {
2425
+ recordedStatuses.push(value);
2426
+ }
2427
+ originalSetStatus(key, value);
2428
+ };
2429
+ await pi.emit("session_start", { reason: "startup" }, ctx);
2430
+
2431
+ await pi.commands.get("req-change")!.handler("Adjust docs", ctx);
2432
+ const promptText = String(pi.sentUserMessages[0]?.content ?? "");
2433
+ const worktreeMatch = promptText.match(/created worktree-dir `([^`]+)` and prepared context-path `([^`]+)`\./);
2434
+ assert.ok(worktreeMatch, promptText);
2435
+ const worktreeName = worktreeMatch?.[1] ?? "";
2436
+ const executionBasePath = worktreeMatch?.[2] ?? "";
2437
+
2438
+ await pi.emit("before_agent_start", {}, ctx);
2439
+ await pi.emit("agent_start", {}, ctx);
2440
+ fs.writeFileSync(path.join(projectBase, "divergent.txt"), "root\n", "utf8");
2441
+ assert.equal(spawnSync("git", ["add", "divergent.txt"], { cwd: projectBase, encoding: "utf8" }).status, 0);
2442
+ const rootCommit = spawnSync("git", ["commit", "-m", "root change"], { cwd: projectBase, encoding: "utf8" });
2443
+ assert.equal(rootCommit.status, 0, rootCommit.stderr);
2444
+ fs.mkdirSync(path.join(executionBasePath, "src"), { recursive: true });
2445
+ fs.writeFileSync(path.join(executionBasePath, "src", "merge-failed.ts"), "export const MERGE_FAILED = 1;\n", "utf8");
2446
+ assert.equal(spawnSync("git", ["add", "src/merge-failed.ts"], { cwd: executionBasePath, encoding: "utf8" }).status, 0);
2447
+ const worktreeCommit = spawnSync("git", ["commit", "-m", "worktree change"], { cwd: executionBasePath, encoding: "utf8" });
2448
+ assert.equal(worktreeCommit.status, 0, worktreeCommit.stderr);
2449
+
2450
+ await pi.emit("agent_end", {
2451
+ messages: [{ role: "assistant", stopReason: "stop", content: [] }],
2452
+ }, ctx);
2453
+
2454
+ assert.equal(process.cwd(), projectBase);
2455
+ assert.equal(ctx.cwd, projectBase);
2456
+ assert.equal(fs.existsSync(executionBasePath), true);
2457
+ assert.equal(fs.existsSync(path.join(projectBase, "src", "merge-failed.ts")), false);
2458
+ assert.ok(fs.existsSync(path.join(projectBase, "divergent.txt")));
2459
+ assert.equal(
2460
+ spawnSync("git", ["show-ref", "--verify", "--quiet", `refs/heads/${worktreeName}`], {
2461
+ cwd: projectBase,
2462
+ encoding: "utf8",
2463
+ }).status,
2464
+ 0,
2465
+ );
2466
+ assert.ok(recordedStatuses.some((status) => /<accent>status:<\/accent><warning>merging<\/warning>/.test(status)));
2467
+ assert.ok(recordedStatuses.some((status) => /<accent>status:<\/accent><error>\u001b\[5merror\u001b\[25m<\/error>/.test(status)));
2468
+ assert.match(ctx.__state.statuses.get("pi-usereq") ?? "", /<accent>status:<\/accent><warning>idle<\/warning>/);
2469
+ assert.ok(ctx.__state.notifications.some((entry) => /Fast-forward merge failed/.test(entry.message)));
2470
+ } finally {
2471
+ process.chdir(previousCwd);
2472
+ fs.rmSync(projectBase, { recursive: true, force: true });
2473
+ }
2474
+ });
2475
+
2476
+ test("worktree-enabled prompt commands honor overridden worktree prefixes", async () => {
2477
+ const { projectBase } = initFixtureRepo({ fixtures: [] });
2478
+ const previousCwd = process.cwd();
2479
+ try {
2480
+ writeProjectConfigOverrides(projectBase, { GIT_WORKTREE_PREFIX: "custom-prefix-" });
2481
+ const pi = createFakePi();
2482
+ piUsereqExtension(pi);
2483
+ const ctx = createFakeCtx(projectBase);
2484
+
2485
+ await pi.commands.get("req-change")!.handler("Adjust docs", ctx);
2486
+ const promptText = String(pi.sentUserMessages[0]?.content ?? "");
2487
+ const worktreeMatch = promptText.match(/created worktree-dir `([^`]+)` and prepared context-path `([^`]+)`\./);
2488
+ assert.ok(worktreeMatch, promptText);
2489
+ assert.match(worktreeMatch?.[1] ?? "", /^custom-prefix-/);
2490
+ } finally {
2491
+ process.chdir(previousCwd);
2492
+ fs.rmSync(projectBase, { recursive: true, force: true });
2493
+ }
2494
+ });
2495
+
2496
+ test("worktree-backed prompt failures skip merge, restore base-path, and retain worktree", async () => {
2497
+ const { projectBase } = initFixtureRepo({ fixtures: [] });
2498
+ const previousCwd = process.cwd();
2499
+ try {
2500
+ const pi = createFakePi();
2501
+ piUsereqExtension(pi);
2502
+ const ctx = createFakeCtx(projectBase);
2503
+ await pi.emit("session_start", { reason: "startup" }, ctx);
2504
+
2505
+ await pi.commands.get("req-change")!.handler("Adjust docs", ctx);
2506
+ const promptText = String(pi.sentUserMessages[0]?.content ?? "");
2507
+ const worktreeMatch = promptText.match(/created worktree-dir `([^`]+)` and prepared context-path `([^`]+)`\./);
2508
+ assert.ok(worktreeMatch, promptText);
2509
+ const worktreeName = worktreeMatch?.[1] ?? "";
2510
+ const executionBasePath = worktreeMatch?.[2] ?? "";
2511
+
2512
+ await pi.emit("before_agent_start", {}, ctx);
2513
+ assert.equal(process.cwd(), executionBasePath);
2514
+ await pi.emit("agent_start", {}, ctx);
2515
+ fs.mkdirSync(path.join(executionBasePath, "src"), { recursive: true });
2516
+ fs.writeFileSync(path.join(executionBasePath, "src", "failed.ts"), "export const FAILED = 1;\n", "utf8");
2517
+ await pi.emit("agent_end", {
2518
+ messages: [{ role: "assistant", stopReason: "error", content: [] }],
2519
+ }, ctx);
2520
+
2521
+ assert.equal(process.cwd(), projectBase);
2522
+ assert.equal(ctx.cwd, projectBase);
2523
+ assert.equal(fs.existsSync(executionBasePath), true);
2524
+ assert.equal(fs.existsSync(path.join(projectBase, "src", "failed.ts")), false);
2525
+ assert.equal(
2526
+ spawnSync("git", ["show-ref", "--verify", "--quiet", `refs/heads/${worktreeName}`], {
2527
+ cwd: projectBase,
2528
+ encoding: "utf8",
2529
+ }).status,
2530
+ 0,
2531
+ );
2532
+ assert.ok(ctx.__state.notifications.some((entry) => entry.level === "error"
2533
+ && /Prompt closure retained worktree .* after failed outcome\./.test(entry.message)));
2534
+ assert.equal(
2535
+ ctx.__state.statuses.get("pi-usereq"),
2536
+ buildExpectedFakeStatusText({
2537
+ workflowState: "idle",
2538
+ basePath: buildExpectedFakeBasePath(projectBase),
2539
+ docsDir: DEFAULT_DOCS_DIR,
2540
+ testsDir: "tests",
2541
+ srcDir: ["src"],
2542
+ contextFilledCells: 0,
2543
+ et: "⏱︎ --:-- ⚑ 0:00 ⌛︎0:00",
2544
+ }),
2545
+ );
2546
+ } finally {
2547
+ process.chdir(previousCwd);
2548
+ fs.rmSync(projectBase, { recursive: true, force: true });
2549
+ }
2550
+ });
2551
+
2552
+ test("worktree-backed prompt commands use replacement-session callbacks for prompt dispatch and restore base-path after merge", async () => {
2553
+ const { projectBase } = initFixtureRepo({ fixtures: [] });
2554
+ const previousCwd = process.cwd();
2555
+ try {
2556
+ const pi = createFakePi();
2557
+ piUsereqExtension(pi);
2558
+ const ctx = createFakeCtx(projectBase);
2559
+ await pi.emit("session_start", { reason: "startup" }, ctx);
2560
+
2561
+ const originalSessionFile = ctx.sessionManager.getSessionFile();
2562
+ const originalSwitchSession = ctx.switchSession.bind(ctx);
2563
+ let activeSessionCtx: any = ctx;
2564
+ ctx.switchSession = async (
2565
+ sessionPath: string,
2566
+ options?: { withSession?: (replacementCtx: any) => Promise<void> },
2567
+ ) => {
2568
+ const result = await originalSwitchSession(sessionPath);
2569
+ const replacementCwd = readFakeSessionFileCwd(sessionPath, projectBase);
2570
+ const replacementCtx = {
2571
+ ...ctx,
2572
+ cwd: replacementCwd,
2573
+ sessionManager: {
2574
+ ...ctx.sessionManager,
2575
+ getCwd: () => replacementCwd,
2576
+ getSessionFile: () => sessionPath,
2577
+ },
2578
+ async sendUserMessage(content: unknown, sendOptions?: unknown) {
2579
+ pi.sentUserMessages.push({ content, options: sendOptions });
2580
+ },
2581
+ };
2582
+ activeSessionCtx = replacementCtx;
2583
+ if (options?.withSession) {
2584
+ process.chdir(replacementCwd);
2585
+ await options.withSession(replacementCtx);
2586
+ ctx.cwd = projectBase;
2587
+ ctx.sessionManager.getSessionFile = () => originalSessionFile;
2588
+ ctx.sessionManager.getCwd = () => projectBase;
2589
+ return result;
2590
+ }
2591
+ ctx.cwd = projectBase;
2592
+ ctx.sessionManager.getSessionFile = () => originalSessionFile;
2593
+ ctx.sessionManager.getCwd = () => projectBase;
2594
+ process.chdir(projectBase);
2595
+ return result;
2596
+ };
2597
+
2598
+ await pi.commands.get("req-change")!.handler("Adjust docs", ctx);
2599
+
2600
+ assert.equal(pi.sentUserMessages.length, 1);
2601
+ const promptText = String(pi.sentUserMessages[0]?.content ?? "");
2602
+ const worktreeMatch = promptText.match(/created worktree-dir `([^`]+)` and prepared context-path `([^`]+)`\./);
2603
+ assert.ok(worktreeMatch, promptText);
2604
+ const worktreeName = worktreeMatch?.[1] ?? "";
2605
+ const executionBasePath = worktreeMatch?.[2] ?? "";
2606
+ assert.equal(activeSessionCtx.cwd, executionBasePath);
2607
+ assert.equal(process.cwd(), executionBasePath);
2608
+ assert.equal(fs.existsSync(executionBasePath), true);
2609
+
2610
+ await pi.emit("before_agent_start", {}, activeSessionCtx);
2611
+ await pi.emit("agent_start", {}, activeSessionCtx);
2612
+
2613
+ fs.mkdirSync(path.join(executionBasePath, "src"), { recursive: true });
2614
+ fs.writeFileSync(path.join(executionBasePath, "src", "with-session.ts"), "export const WITH_SESSION = 1;\n", "utf8");
2615
+ assert.equal(spawnSync("git", ["add", "src/with-session.ts"], { cwd: executionBasePath, encoding: "utf8" }).status, 0);
2616
+ const worktreeCommit = spawnSync("git", ["commit", "-m", "with session"], {
2617
+ cwd: executionBasePath,
2618
+ encoding: "utf8",
2619
+ });
2620
+ assert.equal(worktreeCommit.status, 0, worktreeCommit.stderr);
2621
+
2622
+ await pi.emit("agent_end", {
2623
+ messages: [{ role: "assistant", stopReason: "stop", content: [] }],
2624
+ }, activeSessionCtx);
2625
+
2626
+ assert.equal(process.cwd(), projectBase);
2627
+ assert.equal(activeSessionCtx.cwd, projectBase);
2628
+ assert.equal(fs.existsSync(executionBasePath), false);
2629
+ assert.equal(fs.existsSync(path.join(projectBase, "src", "with-session.ts")), true);
2630
+ assert.notEqual(
2631
+ spawnSync("git", ["show-ref", "--verify", "--quiet", `refs/heads/${worktreeName}`], {
2632
+ cwd: projectBase,
2633
+ encoding: "utf8",
2634
+ }).status,
2635
+ 0,
2636
+ );
2637
+ } finally {
2638
+ process.chdir(previousCwd);
2639
+ fs.rmSync(projectBase, { recursive: true, force: true });
2640
+ }
2641
+ });
2642
+
2643
+ /**
2644
+ * @brief Verifies worktree-backed prompt orchestration against the pi runtime session-switch contract.
2645
+ * @details Simulates the current pi runtime behavior discovered in `/tmp/pi-mono`: `ctx.switchSession(...)` exposes the replacement session through `withSession(...)` and replacement-session contexts while the host process cwd stays anchored to the original project directory until the extension updates it explicitly. The test proves prompt delivery and prompt-end closure must synchronize `process.cwd()` with the active worktree session during execution and restore `base-path` plus remove the worktree after successful merge even when the pi host leaves cwd unchanged. Runtime is dominated by temporary git worktree setup and teardown. Side effects are limited to temporary repository mutation and temporary session-file writes.
2646
+ * @return {Promise<void>} Promise resolved after prompt delivery, merge handling, and restored-base assertions complete.
2647
+ * @throws {AssertionError} Throws when prompt activation or prompt-end closure fails to synchronize `process.cwd()` with the expected session path.
2648
+ * @satisfies REQ-068, REQ-208, REQ-257, REQ-271, REQ-272
2649
+ */
2650
+ test("worktree-backed prompt commands restore base-path when switchSession does not change process cwd", async () => {
2651
+ const { projectBase } = initFixtureRepo({ fixtures: [] });
2652
+ const previousCwd = process.cwd();
2653
+ try {
2654
+ process.chdir(projectBase);
2655
+ const pi = createFakePi();
2656
+ piUsereqExtension(pi);
2657
+ const ctx = createFakeCtx(projectBase);
2658
+ await pi.emit("session_start", { reason: "startup" }, ctx);
2659
+
2660
+ let activeSessionCtx: any = ctx;
2661
+ ctx.switchSession = async (
2662
+ sessionPath: string,
2663
+ options?: { withSession?: (replacementCtx: any) => Promise<void> },
2664
+ ) => {
2665
+ const replacementCwd = readFakeSessionFileCwd(sessionPath, projectBase);
2666
+ const replacementCtx = {
2667
+ ...ctx,
2668
+ cwd: replacementCwd,
2669
+ sessionManager: {
2670
+ ...ctx.sessionManager,
2671
+ getCwd: () => replacementCwd,
2672
+ getSessionFile: () => sessionPath,
2673
+ },
2674
+ switchSession: ctx.switchSession,
2675
+ async sendUserMessage(content: unknown, sendOptions?: unknown) {
2676
+ pi.sentUserMessages.push({ content, options: sendOptions });
2677
+ },
2678
+ };
2679
+ activeSessionCtx = replacementCtx;
2680
+ await options?.withSession?.(replacementCtx);
2681
+ return { cancelled: false };
2682
+ };
2683
+
2684
+ await pi.commands.get("req-change")!.handler("Adjust docs", ctx);
2685
+
2686
+ assert.equal(pi.sentUserMessages.length, 1);
2687
+ const promptText = String(pi.sentUserMessages[0]?.content ?? "");
2688
+ const worktreeMatch = promptText.match(/created worktree-dir `([^`]+)` and prepared context-path `([^`]+)`\./);
2689
+ assert.ok(worktreeMatch, promptText);
2690
+ const worktreeName = worktreeMatch?.[1] ?? "";
2691
+ const executionBasePath = worktreeMatch?.[2] ?? "";
2692
+ assert.equal(activeSessionCtx.cwd, executionBasePath);
2693
+ assert.equal(activeSessionCtx.sessionManager.getCwd(), executionBasePath);
2694
+ assert.equal(ctx.cwd, projectBase);
2695
+ assert.equal(process.cwd(), executionBasePath);
2696
+
2697
+ await pi.emit("before_agent_start", {}, activeSessionCtx);
2698
+ await pi.emit("agent_start", {}, activeSessionCtx);
2699
+
2700
+ fs.mkdirSync(path.join(executionBasePath, "src"), { recursive: true });
2701
+ fs.writeFileSync(path.join(executionBasePath, "src", "runtime-anchor.ts"), "export const RUNTIME_ANCHOR = 1;\n", "utf8");
2702
+ assert.equal(
2703
+ spawnSync("git", ["add", "src/runtime-anchor.ts"], {
2704
+ cwd: executionBasePath,
2705
+ encoding: "utf8",
2706
+ }).status,
2707
+ 0,
2708
+ );
2709
+ const worktreeCommit = spawnSync("git", ["commit", "-m", "runtime anchor"], {
2710
+ cwd: executionBasePath,
2711
+ encoding: "utf8",
2712
+ });
2713
+ assert.equal(worktreeCommit.status, 0, worktreeCommit.stderr);
2714
+
2715
+ await pi.emit("agent_end", {
2716
+ messages: [{ role: "assistant", stopReason: "stop", content: [] }],
2717
+ }, activeSessionCtx);
2718
+
2719
+ assert.equal(process.cwd(), projectBase);
2720
+ assert.equal(activeSessionCtx.cwd, projectBase);
2721
+ assert.equal(fs.existsSync(executionBasePath), false);
2722
+ assert.equal(fs.existsSync(path.join(projectBase, "src", "runtime-anchor.ts")), true);
2723
+ assert.notEqual(
2724
+ spawnSync("git", ["show-ref", "--verify", "--quiet", `refs/heads/${worktreeName}`], {
2725
+ cwd: projectBase,
2726
+ encoding: "utf8",
2727
+ }).status,
2728
+ 0,
2729
+ );
2730
+ } finally {
2731
+ process.chdir(previousCwd);
2732
+ fs.rmSync(projectBase, { recursive: true, force: true });
2733
+ }
2734
+ });
2735
+
2736
+ /**
2737
+ * @brief Verifies restored base-path closure tolerates stale `ctx.cwd` mutations after a successful session switch.
2738
+ * @details Simulates the real pi runtime behavior observed in `/tmp/debug.json`, where restoring the original session succeeds but the old replacement-session context becomes stale immediately afterward, so mutating its `cwd` mirror throws the documented stale-extension-context error. The test proves `restorePromptCommandExecution(...)` MUST ignore that late stale mutation failure once session-target verification already succeeded, leaving prompt finalization free to continue merge and cleanup.
2739
+ * @return {Promise<void>} Promise resolved after restored-base assertions and cleanup complete.
2740
+ * @throws {AssertionError} Throws when the stale post-restore `cwd` mutation still aborts restoration.
2741
+ * @satisfies REQ-208, REQ-257, REQ-276, REQ-280, REQ-282
2742
+ */
2743
+ test("restorePromptCommandExecution ignores stale ctx.cwd mutations after a verified restore switch", async () => {
2744
+ const { projectBase } = initFixtureRepo({ fixtures: [] });
2745
+ const previousCwd = process.cwd();
2746
+ let executionPlan: ReturnType<typeof preparePromptCommandExecution> | undefined;
2747
+ try {
2748
+ const config = loadConfig(projectBase);
2749
+ const ctx = createFakeCtx(projectBase);
2750
+ executionPlan = preparePromptCommandExecution(
2751
+ "change",
2752
+ "Adjust docs",
2753
+ projectBase,
2754
+ config,
2755
+ ctx.sessionManager.getSessionFile(),
2756
+ ctx.sessionManager.getSessionDir?.(),
2757
+ ctx.sessionManager.getBranch?.(),
2758
+ );
2759
+ const activeContext = await activatePromptCommandExecution(executionPlan, ctx);
2760
+ assert.ok(activeContext);
2761
+ let activeContextCwd = activeContext?.cwd;
2762
+ let staleAfterSwitch = false;
2763
+ Object.defineProperty(activeContext!, "cwd", {
2764
+ configurable: true,
2765
+ get() {
2766
+ return activeContextCwd;
2767
+ },
2768
+ set(value: string) {
2769
+ if (staleAfterSwitch) {
2770
+ throw new Error("This extension ctx is stale after session replacement or reload.");
2771
+ }
2772
+ activeContextCwd = value;
2773
+ },
2774
+ });
2775
+ activeContext!.switchSession = async (sessionPath: string) => {
2776
+ activeContextCwd = projectBase;
2777
+ activeContext!.sessionManager.getSessionFile = () => sessionPath;
2778
+ activeContext!.sessionManager.getSessionDir = () => path.dirname(sessionPath);
2779
+ activeContext!.sessionManager.getCwd = () => projectBase;
2780
+ process.chdir(projectBase);
2781
+ staleAfterSwitch = true;
2782
+ return { cancelled: false };
2783
+ };
2784
+
2785
+ const restoredContext = await restorePromptCommandExecution(executionPlan, activeContext, {
2786
+ config,
2787
+ workflowState: "merging",
2788
+ });
2789
+
2790
+ assert.equal(process.cwd(), projectBase);
2791
+ assert.equal(restoredContext, activeContext);
2792
+ assert.equal(fs.existsSync(executionPlan.worktreePath ?? ""), true);
2793
+ } finally {
2794
+ if (executionPlan) {
2795
+ await abortPromptCommandExecution(executionPlan);
2796
+ }
2797
+ process.chdir(previousCwd);
2798
+ fs.rmSync(projectBase, { recursive: true, force: true });
2799
+ }
2800
+ });
2801
+
2802
+ /**
2803
+ * @brief Verifies restored base-path closure tolerates getter-only `ctx.cwd` mirrors after a successful session switch.
2804
+ * @details Simulates the real pi replacement-session context shape observed during `/tmp/debug.json` triage, where `cwd` is exposed through a getter-only property. The test proves `restorePromptCommandExecution(...)` MUST treat a non-writable `cwd` mirror as advisory-only after session-target verification succeeds, instead of aborting prompt finalization with a TypeError before merge.
2805
+ * @return {Promise<void>} Promise resolved after restored-base assertions and cleanup complete.
2806
+ * @throws {AssertionError} Throws when a getter-only `cwd` property still aborts restoration.
2807
+ * @satisfies REQ-208, REQ-257, REQ-276, REQ-282
2808
+ */
2809
+ test("restorePromptCommandExecution ignores getter-only ctx.cwd mirrors after a verified restore switch", async () => {
2810
+ const { projectBase } = initFixtureRepo({ fixtures: [] });
2811
+ const previousCwd = process.cwd();
2812
+ let executionPlan: ReturnType<typeof preparePromptCommandExecution> | undefined;
2813
+ try {
2814
+ const config = loadConfig(projectBase);
2815
+ const ctx = createFakeCtx(projectBase);
2816
+ executionPlan = preparePromptCommandExecution(
2817
+ "change",
2818
+ "Adjust docs",
2819
+ projectBase,
2820
+ config,
2821
+ ctx.sessionManager.getSessionFile(),
2822
+ ctx.sessionManager.getSessionDir?.(),
2823
+ ctx.sessionManager.getBranch?.(),
2824
+ );
2825
+ const activeContext = await activatePromptCommandExecution(executionPlan, ctx);
2826
+ assert.ok(activeContext);
2827
+ let activeContextCwd = activeContext?.cwd;
2828
+ Object.defineProperty(activeContext!, "cwd", {
2829
+ configurable: true,
2830
+ get() {
2831
+ return activeContextCwd;
2832
+ },
2833
+ });
2834
+ activeContext!.switchSession = async (sessionPath: string) => {
2835
+ activeContextCwd = projectBase;
2836
+ activeContext!.sessionManager.getSessionFile = () => sessionPath;
2837
+ activeContext!.sessionManager.getSessionDir = () => path.dirname(sessionPath);
2838
+ activeContext!.sessionManager.getCwd = () => projectBase;
2839
+ process.chdir(projectBase);
2840
+ return { cancelled: false };
2841
+ };
2842
+
2843
+ const restoredContext = await restorePromptCommandExecution(executionPlan, activeContext, {
2844
+ config,
2845
+ workflowState: "merging",
2846
+ });
2847
+
2848
+ assert.equal(process.cwd(), projectBase);
2849
+ assert.equal(restoredContext, activeContext);
2850
+ assert.equal(fs.existsSync(executionPlan.worktreePath ?? ""), true);
2851
+ } finally {
2852
+ if (executionPlan) {
2853
+ await abortPromptCommandExecution(executionPlan);
2854
+ }
2855
+ process.chdir(previousCwd);
2856
+ fs.rmSync(projectBase, { recursive: true, force: true });
2857
+ }
2858
+ });
2859
+
2860
+ /**
2861
+ * @brief Verifies worktree-backed closure succeeds when lifecycle event contexts omit `switchSession()`.
2862
+ * @details Simulates the documented pi extension contract where lifecycle hooks receive `ExtensionContext` rather than `ExtensionCommandContext`, so `agent_end` contexts expose the active execution-session file and cwd but omit session-replacement methods. The test proves closure must reuse the persisted command-capable replacement-session context captured during activation to restore `base-path`, merge the successful worktree branch, and delete the worktree plus branch. Runtime is dominated by temporary git worktree setup, commit creation, and prompt finalization. Side effects are limited to temporary repository mutation and temporary session-file writes.
2863
+ * @return {Promise<void>} Promise resolved after closure assertions complete.
2864
+ * @throws {AssertionError} Throws when closure fails to restore `base-path`, merge the worktree, or remove worktree resources.
2865
+ * @satisfies REQ-208, REQ-209, REQ-257, REQ-272, REQ-276, TST-089
2866
+ */
2867
+ test("worktree-backed closure succeeds when agent_end lifecycle contexts omit switchSession", async () => {
2868
+ const { projectBase } = initFixtureRepo({ fixtures: [] });
2869
+ const previousCwd = process.cwd();
2870
+ try {
2871
+ const pi = createFakePi();
2872
+ piUsereqExtension(pi);
2873
+ const commandCtx = createFakeCtx(projectBase);
2874
+ await pi.emit("session_start", { reason: "startup" }, commandCtx);
2875
+
2876
+ await pi.commands.get("req-change")!.handler("Adjust docs", commandCtx);
2877
+ const promptText = String(pi.sentUserMessages[0]?.content ?? "");
2878
+ const worktreeMatch = promptText.match(/created worktree-dir `([^`]+)` and prepared context-path `([^`]+)`\./);
2879
+ assert.ok(worktreeMatch, promptText);
2880
+ const worktreeName = worktreeMatch?.[1] ?? "";
2881
+ const executionBasePath = worktreeMatch?.[2] ?? "";
2882
+ const executionSessionFile = commandCtx.sessionManager.getSessionFile() ?? "";
2883
+ assert.notEqual(executionSessionFile, "");
2884
+
2885
+ const eventCtx = createFakeCtx(executionBasePath);
2886
+ eventCtx.sessionManager.getSessionFile = () => executionSessionFile;
2887
+ eventCtx.sessionManager.getSessionDir = () => path.dirname(executionSessionFile);
2888
+ eventCtx.sessionManager.getCwd = () => executionBasePath;
2889
+ eventCtx.cwd = executionBasePath;
2890
+ delete eventCtx.switchSession;
2891
+
2892
+ await pi.emit("before_agent_start", {}, eventCtx);
2893
+ await pi.emit("agent_start", {}, eventCtx);
2894
+ fs.mkdirSync(path.join(executionBasePath, "src"), { recursive: true });
2895
+ fs.writeFileSync(path.join(executionBasePath, "src", "closure-context.ts"), "export const CLOSURE_CONTEXT = 1;\n", "utf8");
2896
+ assert.equal(spawnSync("git", ["add", "src/closure-context.ts"], { cwd: executionBasePath, encoding: "utf8" }).status, 0);
2897
+ const worktreeCommit = spawnSync("git", ["commit", "-m", "closure context"], {
2898
+ cwd: executionBasePath,
2899
+ encoding: "utf8",
2900
+ });
2901
+ assert.equal(worktreeCommit.status, 0, worktreeCommit.stderr);
2902
+
2903
+ await pi.emit("agent_end", {
2904
+ messages: [{ role: "assistant", stopReason: "stop", content: [] }],
2905
+ }, eventCtx);
2906
+
2907
+ assert.equal(process.cwd(), projectBase);
2908
+ assert.equal(commandCtx.cwd, projectBase);
2909
+ assert.ok(fs.existsSync(path.join(projectBase, "src", "closure-context.ts")));
2910
+ assert.equal(fs.existsSync(executionBasePath), false);
2911
+ assert.notEqual(
2912
+ spawnSync("git", ["show-ref", "--verify", "--quiet", `refs/heads/${worktreeName}`], {
2913
+ cwd: projectBase,
2914
+ encoding: "utf8",
2915
+ }).status,
2916
+ 0,
2917
+ );
2918
+ assert.equal(commandCtx.__state.notifications.filter((entry) => entry.level === "error").length, 0);
2919
+ } finally {
2920
+ process.chdir(previousCwd);
2921
+ fs.rmSync(projectBase, { recursive: true, force: true });
2922
+ }
2923
+ });
2924
+
2925
+ /**
2926
+ * @brief Verifies switch-triggered `session_shutdown` preserves prompt-command state for same-runtime workflow closure.
2927
+ * @details Simulates the real pi runtime ordering captured in `/tmp/debug.json`, where `ctx.switchSession(...)` can trigger `session_shutdown` before the initiating command handler resumes. The test proves the initiating runtime must preserve its in-memory workflow state plus pending prompt request across that shutdown so activation remains in `checking`, the handler transitions to `running`, and later `agent_end` restores `base-path`, merges the successful worktree branch, and deletes the worktree plus branch.
2928
+ * @return {Promise<void>} Promise resolved after session-shutdown preservation and closure assertions complete.
2929
+ * @throws {AssertionError} Throws when switch-triggered shutdown clears prompt state, logs the wrong workflow state, or prevents merge and cleanup.
2930
+ * @satisfies REQ-221, REQ-227, REQ-278, TST-090
2931
+ */
2932
+ test("switch-triggered session_shutdown preserves prompt state for same-runtime workflow closure", async () => {
2933
+ const { projectBase } = initFixtureRepo({ fixtures: [] });
2934
+ const previousCwd = process.cwd();
2935
+ try {
2936
+ writeProjectConfigOverrides(projectBase, {
2937
+ DEBUG_ENABLED: "enable",
2938
+ DEBUG_LOG_FILE: "prompt-debug.json",
2939
+ DEBUG_STATUS_CHANGES: "enable",
2940
+ DEBUG_WORKFLOW_EVENTS: "enable",
2941
+ DEBUG_LOG_ON_STATUS: "any",
2942
+ DEBUG_ENABLED_PROMPTS: ["req-change"],
2943
+ });
2944
+ const pi = createFakePi();
2945
+ piUsereqExtension(pi);
2946
+ const ctx = createFakeCtx(projectBase);
2947
+ await pi.emit("session_start", { reason: "startup" }, ctx);
2948
+
2949
+ const originalSwitchSession = ctx.switchSession.bind(ctx);
2950
+ ctx.switchSession = async (sessionPath: string) => {
2951
+ await pi.emit("session_shutdown", {
2952
+ reason: "resume",
2953
+ targetSessionFile: sessionPath,
2954
+ }, ctx);
2955
+ return originalSwitchSession(sessionPath);
2956
+ };
2957
+
2958
+ await pi.commands.get("req-change")!.handler("Adjust docs", ctx);
2959
+ const promptText = String(pi.sentUserMessages[0]?.content ?? "");
2960
+ const worktreeMatch = promptText.match(/created worktree-dir `([^`]+)` and prepared context-path `([^`]+)`\./);
2961
+ assert.ok(worktreeMatch, promptText);
2962
+ const executionBasePath = worktreeMatch?.[2] ?? "";
2963
+
2964
+ const activationLog = JSON.parse(fs.readFileSync(path.join(projectBase, "prompt-debug.json"), "utf8"));
2965
+ const shutdownEntry = activationLog.find((entry: any) => entry.action === "workflow_session_shutdown");
2966
+ assert.equal(shutdownEntry?.workflow_state, "checking");
2967
+ assert.equal(shutdownEntry?.result?.preserve_prompt_command_state, true);
2968
+ assert.equal(shutdownEntry?.result?.pending_prompt_request, true);
2969
+ assert.equal(shutdownEntry?.result?.active_prompt_request, false);
2970
+ assert.ok(activationLog.some((entry: any) => entry.action === "workflow_activation" && entry.workflow_state === "checking"));
2971
+ assert.ok(
2972
+ activationLog.some(
2973
+ (entry: any) => entry.action === "workflow_state"
2974
+ && entry.input?.from_state === "checking"
2975
+ && entry.result?.workflow_state === "running",
2976
+ ),
2977
+ );
2978
+
2979
+ await pi.emit("before_agent_start", {}, ctx);
2980
+ await pi.emit("agent_start", {}, ctx);
2981
+ fs.mkdirSync(path.join(executionBasePath, "src"), { recursive: true });
2982
+ fs.writeFileSync(path.join(executionBasePath, "src", "shutdown-preserve.ts"), "export const SHUTDOWN_PRESERVE = 1;\n", "utf8");
2983
+ assert.equal(spawnSync("git", ["add", "src/shutdown-preserve.ts"], { cwd: executionBasePath, encoding: "utf8" }).status, 0);
2984
+ const worktreeCommit = spawnSync("git", ["commit", "-m", "shutdown preserve"], {
2985
+ cwd: executionBasePath,
2986
+ encoding: "utf8",
2987
+ });
2988
+ assert.equal(worktreeCommit.status, 0, worktreeCommit.stderr);
2989
+
2990
+ await pi.emit("agent_end", {
2991
+ messages: [{ role: "assistant", stopReason: "stop", content: [] }],
2992
+ }, ctx);
2993
+
2994
+ assert.equal(process.cwd(), projectBase);
2995
+ assert.equal(ctx.cwd, projectBase);
2996
+ assert.ok(fs.existsSync(path.join(projectBase, "src", "shutdown-preserve.ts")));
2997
+ assert.equal(fs.existsSync(executionBasePath), false);
2998
+ assert.equal(ctx.__state.notifications.filter((entry) => entry.level === "error").length, 0);
2999
+ } finally {
3000
+ process.chdir(previousCwd);
3001
+ fs.rmSync(projectBase, { recursive: true, force: true });
3002
+ }
3003
+ });
3004
+
3005
+ /**
3006
+ * @brief Verifies replacement-session runtimes resynchronize persisted prompt state after switch-triggered rebinding.
3007
+ * @details Simulates the real pi ordering where the new execution-session extension instance receives `session_start` before the initiating command handler finishes the post-switch `running` transition. The test proves later lifecycle hooks on the replacement-session runtime must re-read persisted prompt-command state so `agent_end` still recognizes a matched successful run, restores `base-path`, merges the worktree branch, and deletes the worktree plus branch.
3008
+ * @return {Promise<void>} Promise resolved after rebound-session closure assertions complete.
3009
+ * @throws {AssertionError} Throws when the replacement-session runtime misses the persisted `running` state and skips merge plus cleanup.
3010
+ * @satisfies REQ-227, REQ-279, TST-091
3011
+ */
3012
+ test("replacement-session runtime resynchronizes persisted running state before prompt closure", async () => {
3013
+ const { projectBase } = initFixtureRepo({ fixtures: [] });
3014
+ const previousCwd = process.cwd();
3015
+ try {
3016
+ writeProjectConfigOverrides(projectBase, {
3017
+ DEBUG_ENABLED: "enable",
3018
+ DEBUG_LOG_FILE: "prompt-debug.json",
3019
+ DEBUG_STATUS_CHANGES: "enable",
3020
+ DEBUG_WORKFLOW_EVENTS: "enable",
3021
+ DEBUG_LOG_ON_STATUS: "any",
3022
+ DEBUG_ENABLED_PROMPTS: ["req-change"],
3023
+ });
3024
+ const firstPi = createFakePi();
3025
+ piUsereqExtension(firstPi);
3026
+ const firstCtx = createFakeCtx(projectBase);
3027
+ await firstPi.emit("session_start", { reason: "startup" }, firstCtx);
3028
+
3029
+ const originalSessionFile = firstCtx.sessionManager.getSessionFile() ?? "";
3030
+ const originalSwitchSession = firstCtx.switchSession.bind(firstCtx);
3031
+ let reboundPi: FakePi | undefined;
3032
+ let reboundCtx: any;
3033
+ firstCtx.switchSession = async (sessionPath: string) => {
3034
+ const executionSessionCwd = readFakeSessionFileCwd(sessionPath, firstCtx.cwd);
3035
+ await firstPi.emit("session_shutdown", {
3036
+ reason: "resume",
3037
+ targetSessionFile: sessionPath,
3038
+ }, firstCtx);
3039
+ if (!reboundPi) {
3040
+ reboundPi = createFakePi();
3041
+ piUsereqExtension(reboundPi);
3042
+ reboundCtx = createFakeCtx(executionSessionCwd);
3043
+ reboundCtx.sessionManager.getSessionFile = () => sessionPath;
3044
+ reboundCtx.sessionManager.getSessionDir = () => path.dirname(sessionPath);
3045
+ reboundCtx.sessionManager.getCwd = () => executionSessionCwd;
3046
+ reboundCtx.cwd = executionSessionCwd;
3047
+ await reboundPi.emit("session_start", {
3048
+ reason: "resume",
3049
+ previousSessionFile: originalSessionFile,
3050
+ }, reboundCtx);
3051
+ }
3052
+ return originalSwitchSession(sessionPath);
3053
+ };
3054
+
3055
+ await firstPi.commands.get("req-change")!.handler("Adjust docs", firstCtx);
3056
+ assert.ok(reboundPi);
3057
+ assert.ok(reboundCtx);
3058
+ const promptText = String(firstPi.sentUserMessages[0]?.content ?? "");
3059
+ const worktreeMatch = promptText.match(/created worktree-dir `([^`]+)` and prepared context-path `([^`]+)`\./);
3060
+ assert.ok(worktreeMatch, promptText);
3061
+ const executionBasePath = worktreeMatch?.[2] ?? "";
3062
+
3063
+ await reboundPi!.emit("before_agent_start", {}, reboundCtx);
3064
+ await reboundPi!.emit("agent_start", {}, reboundCtx);
3065
+ fs.mkdirSync(path.join(executionBasePath, "src"), { recursive: true });
3066
+ fs.writeFileSync(path.join(executionBasePath, "src", "rebound-running.ts"), "export const REBOUND_RUNNING = 1;\n", "utf8");
3067
+ assert.equal(spawnSync("git", ["add", "src/rebound-running.ts"], { cwd: executionBasePath, encoding: "utf8" }).status, 0);
3068
+ const reboundCommit = spawnSync("git", ["commit", "-m", "rebound running"], { cwd: executionBasePath, encoding: "utf8" });
3069
+ assert.equal(reboundCommit.status, 0, reboundCommit.stderr);
3070
+
3071
+ await reboundPi!.emit("agent_end", {
3072
+ messages: [{ role: "assistant", stopReason: "stop", content: [] }],
3073
+ }, reboundCtx);
3074
+
3075
+ const debugLog = JSON.parse(fs.readFileSync(path.join(projectBase, "prompt-debug.json"), "utf8"));
3076
+ assert.ok(
3077
+ debugLog.some(
3078
+ (entry: any) => entry.action === "workflow_closure"
3079
+ && entry.result?.should_finalize_matched_success === true,
3080
+ ),
3081
+ );
3082
+ assert.equal(process.cwd(), projectBase);
3083
+ assert.equal(reboundCtx.cwd, projectBase);
3084
+ assert.equal(fs.existsSync(executionBasePath), false);
3085
+ assert.equal(fs.existsSync(path.join(projectBase, "src", "rebound-running.ts")), true);
3086
+ assert.equal(reboundCtx.__state.notifications.filter((entry) => entry.level === "error").length, 0);
3087
+ } finally {
3088
+ process.chdir(previousCwd);
3089
+ fs.rmSync(projectBase, { recursive: true, force: true });
3090
+ }
3091
+ });
3092
+
3093
+ /**
3094
+ * @brief Verifies `running` is persisted before async replacement-session prompt delivery completes.
3095
+ * @details Simulates the real pi runtime behavior visible in `/tmp/debug.json`, where replacement-session `sendUserMessage(...)` does not resolve until the prompt run has already reached `agent_end`. The test proves `req-<prompt>` must transition and persist `running` immediately after prompt handoff begins, so a rebound execution-session runtime can still finalize the matched successful closure while the initiating command handler remains blocked on prompt-delivery completion.
3096
+ * @return {Promise<void>} Promise resolved after delayed prompt delivery, rebound closure, and cleanup assertions complete.
3097
+ * @throws {AssertionError} Throws when `running` is delayed until after `agent_end`, causing closure to skip merge or cleanup.
3098
+ * @satisfies REQ-227, REQ-279, REQ-281, TST-093
3099
+ */
3100
+ test("replacement-session prompt delivery persists running before async sendUserMessage resolves", async () => {
3101
+ const { projectBase } = initFixtureRepo({ fixtures: [] });
3102
+ const previousCwd = process.cwd();
3103
+ try {
3104
+ writeProjectConfigOverrides(projectBase, {
3105
+ DEBUG_ENABLED: "enable",
3106
+ DEBUG_LOG_FILE: "prompt-debug.json",
3107
+ DEBUG_STATUS_CHANGES: "enable",
3108
+ DEBUG_WORKFLOW_EVENTS: "enable",
3109
+ DEBUG_LOG_ON_STATUS: "any",
3110
+ DEBUG_ENABLED_PROMPTS: ["req-change"],
3111
+ });
3112
+ const firstPi = createFakePi();
3113
+ piUsereqExtension(firstPi);
3114
+ const firstCtx = createFakeCtx(projectBase);
3115
+ await firstPi.emit("session_start", { reason: "startup" }, firstCtx);
3116
+
3117
+ const originalSessionFile = firstCtx.sessionManager.getSessionFile() ?? "";
3118
+ const originalSwitchSession = firstCtx.switchSession.bind(firstCtx);
3119
+ let reboundPi: FakePi | undefined;
3120
+ let reboundCtx: any;
3121
+ let resolvePromptDelivery: (() => void) | undefined;
3122
+ let handoffStatus = "";
3123
+ firstCtx.switchSession = async (
3124
+ sessionPath: string,
3125
+ options?: { withSession?: (replacementCtx: any) => Promise<void> },
3126
+ ) => {
3127
+ const executionSessionCwd = readFakeSessionFileCwd(sessionPath, firstCtx.cwd);
3128
+ await firstPi.emit("session_shutdown", {
3129
+ reason: "resume",
3130
+ targetSessionFile: sessionPath,
3131
+ }, firstCtx);
3132
+ if (!reboundPi) {
3133
+ reboundPi = createFakePi();
3134
+ piUsereqExtension(reboundPi);
3135
+ reboundCtx = createFakeCtx(executionSessionCwd);
3136
+ reboundCtx.sessionManager.getSessionFile = () => sessionPath;
3137
+ reboundCtx.sessionManager.getSessionDir = () => path.dirname(sessionPath);
3138
+ reboundCtx.sessionManager.getCwd = () => executionSessionCwd;
3139
+ reboundCtx.cwd = executionSessionCwd;
3140
+ await reboundPi.emit("session_start", {
3141
+ reason: "resume",
3142
+ previousSessionFile: originalSessionFile,
3143
+ }, reboundCtx);
3144
+ }
3145
+ const switchResult = await originalSwitchSession(sessionPath);
3146
+ const replacementCtx = {
3147
+ ...firstCtx,
3148
+ cwd: executionSessionCwd,
3149
+ sessionManager: {
3150
+ ...firstCtx.sessionManager,
3151
+ getCwd: () => executionSessionCwd,
3152
+ getSessionFile: () => sessionPath,
3153
+ getSessionDir: () => path.dirname(sessionPath),
3154
+ },
3155
+ switchSession: firstCtx.switchSession,
3156
+ async sendUserMessage(content: unknown, sendOptions?: unknown) {
3157
+ handoffStatus = firstCtx.__state.statuses.get("pi-usereq") ?? "";
3158
+ firstPi.sentUserMessages.push({ content, options: sendOptions });
3159
+ await new Promise<void>((resolve) => {
3160
+ resolvePromptDelivery = resolve;
3161
+ });
3162
+ },
3163
+ };
3164
+ await options?.withSession?.(replacementCtx);
3165
+ return switchResult;
3166
+ };
3167
+
3168
+ const handlerPromise = firstPi.commands.get("req-change")!.handler("Adjust docs", firstCtx);
3169
+ for (let attempt = 0; attempt < 20 && resolvePromptDelivery === undefined; attempt += 1) {
3170
+ await new Promise((resolve) => setTimeout(resolve, 0));
3171
+ }
3172
+ assert.ok(resolvePromptDelivery);
3173
+ assert.ok(reboundPi);
3174
+ assert.ok(reboundCtx);
3175
+ const promptText = String(firstPi.sentUserMessages[0]?.content ?? "");
3176
+ const worktreeMatch = promptText.match(/created worktree-dir `([^`]+)` and prepared context-path `([^`]+)`\./);
3177
+ assert.ok(worktreeMatch, promptText);
3178
+ const executionBasePath = worktreeMatch?.[2] ?? "";
3179
+
3180
+ assert.equal(
3181
+ handoffStatus,
3182
+ buildExpectedFakeStatusText({
3183
+ workflowState: "checking",
3184
+ basePath: buildExpectedFakeBasePath(executionBasePath),
3185
+ docsDir: DEFAULT_DOCS_DIR,
3186
+ testsDir: "tests",
3187
+ srcDir: ["src"],
3188
+ contextFilledCells: 0,
3189
+ et: "⏱︎ --:-- ⚑ --:-- ⌛︎--:--",
3190
+ }),
3191
+ );
3192
+ assert.equal(
3193
+ firstCtx.__state.statuses.get("pi-usereq"),
3194
+ buildExpectedFakeStatusText({
3195
+ workflowState: "running",
3196
+ basePath: buildExpectedFakeBasePath(executionBasePath),
3197
+ docsDir: DEFAULT_DOCS_DIR,
3198
+ testsDir: "tests",
3199
+ srcDir: ["src"],
3200
+ contextFilledCells: 0,
3201
+ et: "⏱︎ --:-- ⚑ --:-- ⌛︎--:--",
3202
+ }),
3203
+ );
3204
+
3205
+ await reboundPi!.emit("before_agent_start", {}, reboundCtx);
3206
+ await reboundPi!.emit("agent_start", {}, reboundCtx);
3207
+ fs.mkdirSync(path.join(executionBasePath, "src"), { recursive: true });
3208
+ fs.writeFileSync(path.join(executionBasePath, "src", "delayed-running.ts"), "export const DELAYED_RUNNING = 1;\n", "utf8");
3209
+ assert.equal(spawnSync("git", ["add", "src/delayed-running.ts"], { cwd: executionBasePath, encoding: "utf8" }).status, 0);
3210
+ const reboundCommit = spawnSync("git", ["commit", "-m", "delayed running"], { cwd: executionBasePath, encoding: "utf8" });
3211
+ assert.equal(reboundCommit.status, 0, reboundCommit.stderr);
3212
+
3213
+ await reboundPi!.emit("agent_end", {
3214
+ messages: [{ role: "assistant", stopReason: "stop", content: [] }],
3215
+ }, reboundCtx);
3216
+
3217
+ const debugLog = JSON.parse(fs.readFileSync(path.join(projectBase, "prompt-debug.json"), "utf8"));
3218
+ assert.ok(
3219
+ debugLog.some(
3220
+ (entry: any) => entry.action === "workflow_closure"
3221
+ && entry.result?.should_finalize_matched_success === true,
3222
+ ),
3223
+ );
3224
+
3225
+ resolvePromptDelivery!();
3226
+ await handlerPromise;
3227
+
3228
+ assert.equal(process.cwd(), projectBase);
3229
+ assert.equal(reboundCtx.cwd, projectBase);
3230
+ assert.equal(fs.existsSync(executionBasePath), false);
3231
+ assert.equal(fs.existsSync(path.join(projectBase, "src", "delayed-running.ts")), true);
3232
+ assert.equal(reboundCtx.__state.notifications.filter((entry) => entry.level === "error").length, 0);
3233
+ } finally {
3234
+ process.chdir(previousCwd);
3235
+ fs.rmSync(projectBase, { recursive: true, force: true });
3236
+ }
3237
+ });
3238
+
3239
+ /**
3240
+ * @brief Verifies late stale replacement-session prompt-delivery rejections do not abort successful worktree closure.
3241
+ * @details Simulates the real pi runtime behavior observed in `/tmp/debug.json`, where the replacement-session `sendUserMessage(...)` promise rejects with the stale-extension-context error only after prompt-end restoration switches back to the original session. The test proves the initiating `req-<prompt>` handler MUST ignore that late stale rejection once workflow ownership has already advanced beyond `running`, allowing the rebound execution-session runtime to restore `base-path`, merge from `base-path`, and delete the worktree plus branch.
3242
+ * @return {Promise<void>} Promise resolved after stale delivery rejection, matched-success closure, and cleanup assertions complete.
3243
+ * @throws {AssertionError} Throws when the late stale rejection bubbles out of the command handler or prevents merge plus cleanup.
3244
+ * @satisfies REQ-208, REQ-276, REQ-278, REQ-279, REQ-280, REQ-281, REQ-282
3245
+ */
3246
+ test("late stale replacement-session prompt-delivery rejections do not interrupt worktree closure", async () => {
3247
+ const { projectBase } = initFixtureRepo({ fixtures: [] });
3248
+ const previousCwd = process.cwd();
3249
+ try {
3250
+ writeProjectConfigOverrides(projectBase, {
3251
+ DEBUG_ENABLED: "enable",
3252
+ DEBUG_LOG_FILE: "prompt-debug.json",
3253
+ DEBUG_STATUS_CHANGES: "enable",
3254
+ DEBUG_WORKFLOW_EVENTS: "enable",
3255
+ DEBUG_LOG_ON_STATUS: "any",
3256
+ DEBUG_ENABLED_PROMPTS: ["req-change"],
3257
+ });
3258
+ const firstPi = createFakePi();
3259
+ piUsereqExtension(firstPi);
3260
+ const firstCtx = createFakeCtx(projectBase);
3261
+ await firstPi.emit("session_start", { reason: "startup" }, firstCtx);
3262
+
3263
+ const originalSessionFile = firstCtx.sessionManager.getSessionFile() ?? "";
3264
+ const originalSwitchSession = firstCtx.switchSession.bind(firstCtx);
3265
+ let reboundPi: FakePi | undefined;
3266
+ let reboundCtx: any;
3267
+ let rejectPromptDelivery: ((error: unknown) => void) | undefined;
3268
+ firstCtx.switchSession = async (
3269
+ sessionPath: string,
3270
+ options?: { withSession?: (replacementCtx: any) => Promise<void> },
3271
+ ) => {
3272
+ const replacementCwd = readFakeSessionFileCwd(sessionPath, firstCtx.cwd);
3273
+ await firstPi.emit("session_shutdown", {
3274
+ reason: "resume",
3275
+ targetSessionFile: sessionPath,
3276
+ }, firstCtx);
3277
+ if (!reboundPi) {
3278
+ reboundPi = createFakePi();
3279
+ piUsereqExtension(reboundPi);
3280
+ reboundCtx = createFakeCtx(replacementCwd);
3281
+ reboundCtx.sessionManager.getSessionFile = () => sessionPath;
3282
+ reboundCtx.sessionManager.getSessionDir = () => path.dirname(sessionPath);
3283
+ reboundCtx.sessionManager.getCwd = () => replacementCwd;
3284
+ reboundCtx.cwd = replacementCwd;
3285
+ await reboundPi.emit("session_start", {
3286
+ reason: "resume",
3287
+ previousSessionFile: originalSessionFile,
3288
+ }, reboundCtx);
3289
+ }
3290
+ const switchResult = await originalSwitchSession(sessionPath);
3291
+ const replacementCtx = {
3292
+ ...firstCtx,
3293
+ cwd: replacementCwd,
3294
+ sessionManager: {
3295
+ ...firstCtx.sessionManager,
3296
+ getCwd: () => replacementCwd,
3297
+ getSessionFile: () => sessionPath,
3298
+ getSessionDir: () => path.dirname(sessionPath),
3299
+ },
3300
+ switchSession: firstCtx.switchSession,
3301
+ async sendUserMessage(content: unknown, sendOptions?: unknown) {
3302
+ firstPi.sentUserMessages.push({ content, options: sendOptions });
3303
+ await new Promise<void>((_resolve, reject) => {
3304
+ rejectPromptDelivery = reject;
3305
+ });
3306
+ },
3307
+ };
3308
+ if (path.resolve(sessionPath) === path.resolve(originalSessionFile)) {
3309
+ rejectPromptDelivery?.(
3310
+ new Error("This extension ctx is stale after session replacement or reload."),
3311
+ );
3312
+ rejectPromptDelivery = undefined;
3313
+ }
3314
+ await options?.withSession?.(replacementCtx);
3315
+ return switchResult;
3316
+ };
3317
+
3318
+ const handlerPromise = firstPi.commands.get("req-change")!.handler("Adjust docs", firstCtx);
3319
+ const handlerOutcomePromise = handlerPromise.then(
3320
+ () => ({ error: undefined as unknown }),
3321
+ (error) => ({ error }),
3322
+ );
3323
+ for (let attempt = 0; attempt < 20 && rejectPromptDelivery === undefined; attempt += 1) {
3324
+ await new Promise((resolve) => setTimeout(resolve, 0));
3325
+ }
3326
+ assert.ok(rejectPromptDelivery);
3327
+ assert.ok(reboundPi);
3328
+ assert.ok(reboundCtx);
3329
+ const promptText = String(firstPi.sentUserMessages[0]?.content ?? "");
3330
+ const worktreeMatch = promptText.match(/created worktree-dir `([^`]+)` and prepared context-path `([^`]+)`\./);
3331
+ assert.ok(worktreeMatch, promptText);
3332
+ const worktreeName = worktreeMatch?.[1] ?? "";
3333
+ const executionBasePath = worktreeMatch?.[2] ?? "";
3334
+ const executionSessionFile = firstCtx.sessionManager.getSessionFile() ?? "";
3335
+ assert.notEqual(executionSessionFile, "");
3336
+
3337
+ await reboundPi!.emit("before_agent_start", {}, reboundCtx);
3338
+ await reboundPi!.emit("agent_start", {}, reboundCtx);
3339
+
3340
+ const persistedContext = readPersistedPromptCommandSessionContext(executionSessionFile);
3341
+ assert.ok(persistedContext);
3342
+ reboundCtx.switchSession = async (sessionPath: string) => {
3343
+ assert.equal(path.resolve(sessionPath), path.resolve(originalSessionFile));
3344
+ const pendingPromptDeliveryRejection = rejectPromptDelivery;
3345
+ rejectPromptDelivery = undefined;
3346
+ queueMicrotask(() => {
3347
+ pendingPromptDeliveryRejection?.(
3348
+ new Error("This extension ctx is stale after session replacement or reload."),
3349
+ );
3350
+ });
3351
+ reboundCtx.cwd = projectBase;
3352
+ reboundCtx.sessionManager.getSessionFile = () => originalSessionFile;
3353
+ reboundCtx.sessionManager.getSessionDir = () => path.dirname(originalSessionFile);
3354
+ reboundCtx.sessionManager.getCwd = () => projectBase;
3355
+ process.chdir(projectBase);
3356
+ return { cancelled: false };
3357
+ };
3358
+
3359
+ await Promise.race([
3360
+ reboundPi!.emit("agent_end", {
3361
+ messages: [{ role: "assistant", stopReason: "stop", content: [] }],
3362
+ }, reboundCtx),
3363
+ new Promise<never>((_resolve, reject) => {
3364
+ setTimeout(() => reject(new Error("agent_end timed out")), 1_000);
3365
+ }),
3366
+ ]);
3367
+ assert.equal(rejectPromptDelivery, undefined);
3368
+ const handlerOutcome = await Promise.race([
3369
+ handlerOutcomePromise,
3370
+ new Promise<never>((_resolve, reject) => {
3371
+ setTimeout(() => reject(new Error("handler timed out")), 1_000);
3372
+ }),
3373
+ ]);
3374
+ assert.equal(handlerOutcome.error, undefined);
3375
+
3376
+ const debugLog = JSON.parse(fs.readFileSync(path.join(projectBase, "prompt-debug.json"), "utf8"));
3377
+ assert.ok(debugLog.some((entry: any) => entry.action === "workflow_restore" && entry.result?.success === true));
3378
+ assert.ok(debugLog.some((entry: any) => entry.action === "merge" && entry.result?.success === true));
3379
+ assert.equal(process.cwd(), projectBase);
3380
+ assert.equal(reboundCtx.cwd, projectBase);
3381
+ assert.equal(fs.existsSync(executionBasePath), false);
3382
+ assert.notEqual(
3383
+ spawnSync("git", ["show-ref", "--verify", "--quiet", `refs/heads/${worktreeName}`], {
3384
+ cwd: projectBase,
3385
+ encoding: "utf8",
3386
+ }).status,
3387
+ 0,
3388
+ );
3389
+ assert.equal(reboundCtx.__state.notifications.filter((entry) => entry.level === "error").length, 0);
3390
+ } finally {
3391
+ process.chdir(previousCwd);
3392
+ fs.rmSync(projectBase, { recursive: true, force: true });
3393
+ }
3394
+ });
3395
+
3396
+ test("worktree-backed prompt execution survives switch-triggered extension rebinding", async () => {
3397
+ const { projectBase } = initFixtureRepo({ fixtures: [] });
3398
+ const previousCwd = process.cwd();
3399
+ try {
3400
+ const firstPi = createFakePi();
3401
+ piUsereqExtension(firstPi);
3402
+ const firstCtx = createFakeCtx(projectBase);
3403
+ await firstPi.emit("session_start", { reason: "startup" }, firstCtx);
3404
+
3405
+ await firstPi.commands.get("req-change")!.handler("Adjust docs", firstCtx);
3406
+ const promptText = String(firstPi.sentUserMessages[0]?.content ?? "");
3407
+ const worktreeMatch = promptText.match(/created worktree-dir `([^`]+)` and prepared context-path `([^`]+)`\./);
3408
+ assert.ok(worktreeMatch, promptText);
3409
+ const executionBasePath = worktreeMatch?.[2] ?? "";
3410
+ const executionSessionFile = firstCtx.sessionManager.getSessionFile() ?? "";
3411
+ assert.notEqual(executionSessionFile, "");
3412
+
3413
+ const reboundPi = createFakePi();
3414
+ piUsereqExtension(reboundPi);
3415
+ const reboundCtx = createFakeCtx(executionBasePath);
3416
+ reboundCtx.sessionManager.getSessionFile = () => executionSessionFile;
3417
+ reboundCtx.sessionManager.getSessionDir = () => path.dirname(executionSessionFile);
3418
+ reboundCtx.sessionManager.getCwd = () => executionBasePath;
3419
+ reboundCtx.cwd = executionBasePath;
3420
+ await reboundPi.emit("session_start", {
3421
+ reason: "resume",
3422
+ previousSessionFile: firstCtx.sessionManager.getSessionFile(),
3423
+ }, reboundCtx);
3424
+
3425
+ await reboundPi.emit("before_agent_start", {}, reboundCtx);
3426
+ await reboundPi.emit("agent_start", {}, reboundCtx);
3427
+ fs.mkdirSync(path.join(executionBasePath, "src"), { recursive: true });
3428
+ fs.writeFileSync(path.join(executionBasePath, "src", "rebound.ts"), "export const REBOUND = 1;\n", "utf8");
3429
+ assert.equal(spawnSync("git", ["add", "src/rebound.ts"], { cwd: executionBasePath, encoding: "utf8" }).status, 0);
3430
+ const reboundCommit = spawnSync("git", ["commit", "-m", "rebound"], { cwd: executionBasePath, encoding: "utf8" });
3431
+ assert.equal(reboundCommit.status, 0, reboundCommit.stderr);
3432
+
3433
+ await reboundPi.emit("agent_end", {
3434
+ messages: [{ role: "assistant", stopReason: "stop", content: [] }],
3435
+ }, reboundCtx);
3436
+
3437
+ assert.equal(process.cwd(), projectBase);
3438
+ assert.equal(reboundCtx.cwd, projectBase);
3439
+ assert.equal(fs.existsSync(executionBasePath), false);
3440
+ assert.equal(fs.existsSync(path.join(projectBase, "src", "rebound.ts")), true);
3441
+ assert.equal(reboundCtx.__state.notifications.filter((entry) => entry.level === "error").length, 0);
3442
+ } finally {
3443
+ process.chdir(previousCwd);
3444
+ fs.rmSync(projectBase, { recursive: true, force: true });
3445
+ }
3446
+ });
3447
+
3448
+ /**
3449
+ * @brief Reproduces the defect where `ctx.switchSession(sessionPath)` ignores a `withSession` option and leaves the handler-scoped ctx frozen on the pre-switch session.
3450
+ * @details Simulates the real pi SDK contract observed in `@mariozechner/pi-coding-agent`, where `ctx.switchSession(sessionPath)` accepts a single argument, never invokes caller-supplied callbacks, and never mutates `ctx.cwd` or `ctx.sessionManager`. The test proves that worktree-backed `req-<prompt>` activation must succeed against that contract by relying on the persisted session-file header and `process.cwd()` instead of the stale handler-scoped `ctx` probes. Runtime is dominated by temporary git worktree setup and teardown. Side effects are limited to temporary repository mutation and temporary session-file writes.
3451
+ * @return {Promise<void>} Promise resolved after prompt delivery, activation, and assertions complete.
3452
+ * @throws {AssertionError} Throws when prompt activation aborts even though the persisted execution-session file and the host `process.cwd()` both match the worktree path.
3453
+ * @satisfies REQ-068, REQ-208, REQ-257, REQ-271, REQ-272
3454
+ */
3455
+ test("worktree-backed prompt commands activate when ctx.switchSession only accepts a single argument and leaves handler ctx stale", async () => {
3456
+ const { projectBase } = initFixtureRepo({ fixtures: [] });
3457
+ const previousCwd = process.cwd();
3458
+ try {
3459
+ process.chdir(projectBase);
3460
+ const pi = createFakePi();
3461
+ piUsereqExtension(pi);
3462
+ const ctx = createFakeCtx(projectBase);
3463
+ await pi.emit("session_start", { reason: "startup" }, ctx);
3464
+
3465
+ const frozenSessionFile = ctx.sessionManager.getSessionFile() ?? "";
3466
+ const frozenSessionCwd = ctx.sessionManager.getCwd();
3467
+ ctx.switchSession = async (sessionPath: string, ..._ignored: unknown[]) => {
3468
+ // Mirror the real pi SDK: accept only sessionPath, never mutate ctx.cwd or ctx.sessionManager.
3469
+ void sessionPath;
3470
+ return { cancelled: false };
3471
+ };
3472
+
3473
+ await pi.commands.get("req-change")!.handler("Adjust docs", ctx);
3474
+
3475
+ assert.equal(pi.sentUserMessages.length, 1);
3476
+ const promptText = String(pi.sentUserMessages[0]?.content ?? "");
3477
+ const worktreeMatch = promptText.match(/created worktree-dir `([^`]+)` and prepared context-path `([^`]+)`\./);
3478
+ assert.ok(worktreeMatch, promptText);
3479
+ const executionBasePath = worktreeMatch?.[2] ?? "";
3480
+ assert.equal(process.cwd(), executionBasePath);
3481
+ assert.equal(ctx.cwd, projectBase);
3482
+ assert.equal(ctx.sessionManager.getSessionFile(), frozenSessionFile);
3483
+ assert.equal(ctx.sessionManager.getCwd(), frozenSessionCwd);
3484
+ assert.equal(
3485
+ ctx.__state.notifications.some((entry) => /Prompt execution activation expected/.test(entry.message)),
3486
+ false,
3487
+ );
3488
+ } finally {
3489
+ process.chdir(previousCwd);
3490
+ fs.rmSync(projectBase, { recursive: true, force: true });
3491
+ }
3492
+ });
3493
+
3494
+ test("worktree-backed prompt commands tolerate stale ctx.sessionManager probes after switchSession returns non-cancelled", async () => {
3495
+ const { projectBase } = initFixtureRepo({ fixtures: [] });
3496
+ const previousCwd = process.cwd();
3497
+ try {
3498
+ const pi = createFakePi();
3499
+ piUsereqExtension(pi);
3500
+ const ctx = createFakeCtx(projectBase);
3501
+ await pi.emit("session_start", { reason: "startup" }, ctx);
3502
+
3503
+ const originalSessionFile = ctx.sessionManager.getSessionFile();
3504
+ const originalSessionCwd = ctx.sessionManager.getCwd();
3505
+ const originalSwitchSession = ctx.switchSession.bind(ctx);
3506
+ ctx.switchSession = async (sessionPath: string) => {
3507
+ // Mirror the real pi SDK: session switching runs internally but ctx.sessionManager stays frozen.
3508
+ const result = await originalSwitchSession(sessionPath);
3509
+ ctx.sessionManager.getSessionFile = () => originalSessionFile;
3510
+ ctx.sessionManager.getCwd = () => originalSessionCwd;
3511
+ return result;
3512
+ };
3513
+
3514
+ await pi.commands.get("req-change")!.handler("Adjust docs", ctx);
3515
+
3516
+ assert.equal(pi.sentUserMessages.length, 1);
3517
+ const promptText = String(pi.sentUserMessages[0]?.content ?? "");
3518
+ const worktreeMatch = promptText.match(/created worktree-dir `([^`]+)` and prepared context-path `([^`]+)`\./);
3519
+ assert.ok(worktreeMatch, promptText);
3520
+ const executionBasePath = worktreeMatch?.[2] ?? "";
3521
+ assert.equal(process.cwd(), executionBasePath);
3522
+ assert.equal(ctx.sessionManager.getSessionFile(), originalSessionFile);
3523
+ assert.equal(ctx.sessionManager.getCwd(), originalSessionCwd);
3524
+ assert.equal(
3525
+ ctx.__state.notifications.some((entry) => /Prompt execution activation expected/.test(entry.message)),
3526
+ false,
3527
+ );
3528
+ } finally {
3529
+ process.chdir(previousCwd);
3530
+ fs.rmSync(projectBase, { recursive: true, force: true });
3531
+ }
3532
+ });
3533
+
3534
+ test("worktree-backed prompt commands tolerate a pending-persistence execution-session file when process.cwd() is aligned with worktree-path", async () => {
3535
+ const { projectBase } = initFixtureRepo({ fixtures: [] });
3536
+ const previousCwd = process.cwd();
3537
+ try {
3538
+ const pi = createFakePi();
3539
+ piUsereqExtension(pi);
3540
+ const ctx = createFakeCtx(projectBase);
3541
+ await pi.emit("session_start", { reason: "startup" }, ctx);
3542
+
3543
+ const originalSwitchSession = ctx.switchSession.bind(ctx);
3544
+ ctx.switchSession = async (sessionPath: string) => {
3545
+ const result = await originalSwitchSession(sessionPath);
3546
+ // Simulate pi's lazy session-file persistence: ctx.switchSession returns non-cancelled and realigns process.cwd(), but pi defers writing the session file until the first assistant flush.
3547
+ if (typeof sessionPath === "string" && fs.existsSync(sessionPath)) {
3548
+ fs.rmSync(sessionPath, { force: true });
3549
+ }
3550
+ return result;
3551
+ };
3552
+
3553
+ await pi.commands.get("req-change")!.handler("Adjust docs", ctx);
3554
+
3555
+ assert.equal(pi.sentUserMessages.length, 1);
3556
+ const promptText = String(pi.sentUserMessages[0]?.content ?? "");
3557
+ const worktreeMatch = promptText.match(/created worktree-dir `([^`]+)` and prepared context-path `([^`]+)`\./);
3558
+ assert.ok(worktreeMatch, promptText);
3559
+ const executionBasePath = worktreeMatch?.[2] ?? "";
3560
+ assert.equal(process.cwd(), executionBasePath);
3561
+ assert.equal(
3562
+ ctx.__state.notifications.some((entry) => /Prompt execution activation expected session/.test(entry.message)),
3563
+ false,
3564
+ );
3565
+ } finally {
3566
+ process.chdir(previousCwd);
3567
+ fs.rmSync(projectBase, { recursive: true, force: true });
3568
+ }
3569
+ });
3570
+
3571
+ test("worktree-backed prompt commands abort before prompt dispatch when the persisted execution-session header cwd diverges from worktree-path", async () => {
3572
+ const { projectBase } = initFixtureRepo({ fixtures: [] });
3573
+ const previousCwd = process.cwd();
3574
+ try {
3575
+ const pi = createFakePi();
3576
+ piUsereqExtension(pi);
3577
+ const ctx = createFakeCtx(projectBase);
3578
+ const originalSwitchSession = ctx.switchSession.bind(ctx);
3579
+ ctx.switchSession = async (sessionPath: string) => {
3580
+ const result = await originalSwitchSession(sessionPath);
3581
+ // Simulate pi runtimes that persist a tampered header cwd, forcing the on-disk cwd verification to fail.
3582
+ if (typeof sessionPath === "string" && fs.existsSync(sessionPath)) {
3583
+ const tamperedHeader = `${JSON.stringify({ type: "session", cwd: projectBase })}\n`;
3584
+ fs.writeFileSync(sessionPath, tamperedHeader, "utf8");
3585
+ }
3586
+ return result;
3587
+ };
3588
+
3589
+ await assert.rejects(
3590
+ pi.commands.get("req-change")!.handler("Adjust docs", ctx),
3591
+ /Prompt execution activation expected session/,
3592
+ );
3593
+
3594
+ assert.equal(pi.sentUserMessages.length, 0);
3595
+ assert.ok(ctx.__state.notifications.some((entry) => /Prompt execution activation expected session/.test(entry.message)));
3596
+ } finally {
3597
+ process.chdir(previousCwd);
3598
+ fs.rmSync(projectBase, { recursive: true, force: true });
3599
+ }
3600
+ });
3601
+
3602
+ test("worktree-backed successful merge stops before merge when fork-session verification cannot reattach", async () => {
3603
+ const { projectBase } = initFixtureRepo({ fixtures: [] });
3604
+ const previousCwd = process.cwd();
3605
+ try {
3606
+ const pi = createFakePi();
3607
+ piUsereqExtension(pi);
3608
+ const ctx = createFakeCtx(projectBase);
3609
+ await pi.emit("session_start", { reason: "startup" }, ctx);
3610
+
3611
+ await pi.commands.get("req-change")!.handler("Adjust docs", ctx);
3612
+ const promptText = String(pi.sentUserMessages[0]?.content ?? "");
3613
+ const worktreeMatch = promptText.match(/created worktree-dir `([^`]+)` and prepared context-path `([^`]+)`\./);
3614
+ assert.ok(worktreeMatch, promptText);
3615
+ const executionSessionFile = ctx.sessionManager.getSessionFile();
3616
+ const executionBasePath = worktreeMatch?.[2] ?? "";
3617
+
3618
+ await pi.emit("before_agent_start", {}, ctx);
3619
+ await pi.emit("agent_start", {}, ctx);
3620
+ fs.mkdirSync(path.join(executionBasePath, "src"), { recursive: true });
3621
+ fs.writeFileSync(path.join(executionBasePath, "src", "restore-failed.ts"), "export const RESTORE_FAILED = 1;\n", "utf8");
3622
+ assert.equal(spawnSync("git", ["add", "src/restore-failed.ts"], { cwd: executionBasePath, encoding: "utf8" }).status, 0);
3623
+ const worktreeCommit = spawnSync("git", ["commit", "-m", "worktree change"], { cwd: executionBasePath, encoding: "utf8" });
3624
+ assert.equal(worktreeCommit.status, 0, worktreeCommit.stderr);
3625
+
3626
+ // Simulate a pi runtime that rewrites the execution-session header with a divergent cwd at prompt-end so closure verification must abort before merge.
3627
+ assert.equal(typeof executionSessionFile, "string");
3628
+ if (typeof executionSessionFile === "string" && fs.existsSync(executionSessionFile)) {
3629
+ const tamperedHeader = `${JSON.stringify({ type: "session", cwd: projectBase })}\n`;
3630
+ fs.writeFileSync(executionSessionFile, tamperedHeader, "utf8");
3631
+ }
3632
+ // Make ctx.sessionManager report a stale session file path so base-path restoration performs an explicit switch attempt after finalization verification fails.
3633
+ ctx.sessionManager.getSessionFile = () => path.join(projectBase, "stale-execution-session.jsonl");
3634
+ ctx.switchSession = async (_sessionPath: string) => ({ cancelled: false });
3635
+
3636
+ await pi.emit("agent_end", {
3637
+ messages: [{ role: "assistant", stopReason: "stop", content: [] }],
3638
+ }, ctx);
3639
+
3640
+ assert.equal(process.cwd(), projectBase);
3641
+ assert.equal(ctx.cwd, projectBase);
3642
+ assert.equal(fs.existsSync(executionBasePath), true);
3643
+ assert.equal(fs.existsSync(path.join(projectBase, "src", "restore-failed.ts")), false);
3644
+ assert.ok(ctx.__state.notifications.some((entry) => /Prompt execution finalization expected session/.test(entry.message)));
3645
+ } finally {
3646
+ process.chdir(previousCwd);
3647
+ fs.rmSync(projectBase, { recursive: true, force: true });
3648
+ }
3649
+ });
3650
+
3651
+ /**
3652
+ * @brief Verifies successful closure merges from `base-path` even when end-of-session timing already moved the persisted replacement context off the worktree session.
3653
+ * @details Simulates real pi session-end timing such as automatic compaction or other CLI housekeeping that can advance the live replacement-session context back to the original base session before the `agent_end` lifecycle hook executes. The persisted command-capable context is forced to report the original base session and to reject any attempt to reattach the execution session. The test proves prompt-end closure must restore `base-path` and merge directly from `base-path` using persisted execution artifacts instead of failing on a best-effort reactivation of the worktree session.
3654
+ * @return {Promise<void>} Promise resolved after closure assertions complete.
3655
+ * @throws {AssertionError} Throws when closure attempts an execution-session reattach or skips the merge plus cleanup.
3656
+ * @satisfies REQ-208, REQ-209, REQ-282, TST-094
3657
+ */
3658
+ test("worktree-backed closure merges from base-path when end-of-session timing already moved the persisted context", async () => {
3659
+ const { projectBase } = initFixtureRepo({ fixtures: [] });
3660
+ const previousCwd = process.cwd();
3661
+ try {
3662
+ writeProjectConfigOverrides(projectBase, {
3663
+ DEBUG_ENABLED: "enable",
3664
+ DEBUG_LOG_FILE: "prompt-debug.json",
3665
+ DEBUG_STATUS_CHANGES: "enable",
3666
+ DEBUG_WORKFLOW_EVENTS: "enable",
3667
+ DEBUG_LOG_ON_STATUS: "any",
3668
+ DEBUG_ENABLED_PROMPTS: ["req-change"],
3669
+ });
3670
+ const pi = createFakePi();
3671
+ piUsereqExtension(pi);
3672
+ const commandCtx = createFakeCtx(projectBase);
3673
+ await pi.emit("session_start", { reason: "startup" }, commandCtx);
3674
+
3675
+ const originalSessionFile = commandCtx.sessionManager.getSessionFile() ?? "";
3676
+ await pi.commands.get("req-change")!.handler("Adjust docs", commandCtx);
3677
+ const promptText = String(pi.sentUserMessages[0]?.content ?? "");
3678
+ const worktreeMatch = promptText.match(/created worktree-dir `([^`]+)` and prepared context-path `([^`]+)`\./);
3679
+ assert.ok(worktreeMatch, promptText);
3680
+ const worktreeName = worktreeMatch?.[1] ?? "";
3681
+ const executionBasePath = worktreeMatch?.[2] ?? "";
3682
+ const executionSessionFile = commandCtx.sessionManager.getSessionFile() ?? "";
3683
+ assert.notEqual(executionSessionFile, "");
3684
+
3685
+ await pi.emit("before_agent_start", {}, commandCtx);
3686
+ await pi.emit("agent_start", {}, commandCtx);
3687
+ fs.mkdirSync(path.join(executionBasePath, "src"), { recursive: true });
3688
+ fs.writeFileSync(path.join(executionBasePath, "src", "base-merge.ts"), "export const BASE_MERGE = 1;\n", "utf8");
3689
+ assert.equal(spawnSync("git", ["add", "src/base-merge.ts"], { cwd: executionBasePath, encoding: "utf8" }).status, 0);
3690
+ const worktreeCommit = spawnSync("git", ["commit", "-m", "base merge"], { cwd: executionBasePath, encoding: "utf8" });
3691
+ assert.equal(worktreeCommit.status, 0, worktreeCommit.stderr);
3692
+
3693
+ const persistedContext = readPersistedPromptCommandSessionContext(executionSessionFile) as any;
3694
+ assert.ok(persistedContext);
3695
+ persistedContext.cwd = projectBase;
3696
+ persistedContext.sessionManager.getSessionFile = () => originalSessionFile;
3697
+ persistedContext.sessionManager.getSessionDir = () => path.dirname(originalSessionFile);
3698
+ persistedContext.sessionManager.getCwd = () => projectBase;
3699
+ persistedContext.switchSession = async (sessionPath: string) => {
3700
+ if (path.resolve(sessionPath) !== path.resolve(originalSessionFile)) {
3701
+ throw new Error(`Unexpected execution-session reattach: ${sessionPath}`);
3702
+ }
3703
+ process.chdir(projectBase);
3704
+ return { cancelled: false };
3705
+ };
1538
3706
 
1539
- await command!.handler("", ctx);
3707
+ const eventCtx = createFakeCtx(projectBase);
3708
+ eventCtx.sessionManager.getSessionFile = () => originalSessionFile;
3709
+ eventCtx.sessionManager.getSessionDir = () => path.dirname(originalSessionFile);
3710
+ eventCtx.sessionManager.getCwd = () => projectBase;
3711
+ eventCtx.cwd = projectBase;
3712
+ delete eventCtx.switchSession;
3713
+ process.chdir(projectBase);
1540
3714
 
1541
- const config = JSON.parse(fs.readFileSync(getProjectConfigPath(cwd), "utf8"));
1542
- assert.equal(config["docs-dir"], "docs/custom");
1543
- assert.equal(config["notify-enabled"], false);
1544
- assert.equal(config["notify-sound"], "none");
1545
- assert.equal(config["notify-pushover-enabled"], false);
3715
+ await pi.emit("agent_end", {
3716
+ messages: [{ role: "assistant", stopReason: "stop", content: [] }],
3717
+ }, eventCtx);
3718
+
3719
+ const debugLog = JSON.parse(fs.readFileSync(path.join(projectBase, "prompt-debug.json"), "utf8"));
3720
+ assert.ok(debugLog.some((entry: any) => entry.action === "workflow_restore" && entry.result?.success === true));
3721
+ assert.ok(debugLog.some((entry: any) => entry.action === "merge" && entry.result?.success === true));
3722
+ assert.equal(process.cwd(), projectBase);
3723
+ assert.equal(eventCtx.cwd, projectBase);
3724
+ assert.ok(fs.existsSync(path.join(projectBase, "src", "base-merge.ts")));
3725
+ assert.equal(fs.existsSync(executionBasePath), false);
3726
+ assert.notEqual(
3727
+ spawnSync("git", ["show-ref", "--verify", "--quiet", `refs/heads/${worktreeName}`], {
3728
+ cwd: projectBase,
3729
+ encoding: "utf8",
3730
+ }).status,
3731
+ 0,
3732
+ );
3733
+ assert.equal(eventCtx.__state.notifications.filter((entry) => entry.level === "error").length, 0);
3734
+ } finally {
3735
+ process.chdir(previousCwd);
3736
+ fs.rmSync(projectBase, { recursive: true, force: true });
3737
+ }
1546
3738
  });
1547
3739
 
1548
- test("top-level reset defaults restores all configuration values", async () => {
1549
- const cwd = createTempDir("pi-usereq-menu-reset-all-");
1550
- fs.mkdirSync(path.dirname(getProjectConfigPath(cwd)), { recursive: true });
1551
- const pi = createFakePi();
1552
- piUsereqExtension(pi);
1553
- const command = pi.commands.get("pi-usereq");
1554
- assert.ok(command);
1555
- const ctx = createFakeCtx(cwd, {
1556
- selects: [
1557
- "docs-dir",
1558
- "notifications",
1559
- "Enable notification",
1560
- "Save and close",
1561
- "Reset defaults",
1562
- "Save and close",
1563
- ],
1564
- inputs: ["docs/custom"],
1565
- });
3740
+ /**
3741
+ * @brief Verifies matched successful closure ignores stale session-switch errors after restored base-path verification.
3742
+ * @details Simulates the real pi runtime behavior observed in `/tmp/debug.json`, where switching the persisted command-capable replacement-session context back to the original session completes the base-path reattachment but still surfaces the stale-extension-context error from the invalidated execution-session closure. The test proves prompt-end closure MUST treat that stale switch error as ignorable after the restored base session verifies successfully, then continue the fast-forward merge plus worktree deletion.
3743
+ * @return {Promise<void>} Promise resolved after restored-base verification, merge, and cleanup assertions complete.
3744
+ * @throws {AssertionError} Throws when the stale restore-switch error prevents merge plus cleanup.
3745
+ * @satisfies REQ-208, REQ-276, REQ-280, REQ-282
3746
+ */
3747
+ test("worktree-backed closure ignores stale restore-switch errors after base-path verification", async () => {
3748
+ const { projectBase } = initFixtureRepo({ fixtures: [] });
3749
+ const previousCwd = process.cwd();
3750
+ try {
3751
+ writeProjectConfigOverrides(projectBase, {
3752
+ DEBUG_ENABLED: "enable",
3753
+ DEBUG_LOG_FILE: "prompt-debug.json",
3754
+ DEBUG_STATUS_CHANGES: "enable",
3755
+ DEBUG_WORKFLOW_EVENTS: "enable",
3756
+ DEBUG_LOG_ON_STATUS: "any",
3757
+ DEBUG_ENABLED_PROMPTS: ["req-change"],
3758
+ });
3759
+ const pi = createFakePi();
3760
+ piUsereqExtension(pi);
3761
+ const commandCtx = createFakeCtx(projectBase);
3762
+ await pi.emit("session_start", { reason: "startup" }, commandCtx);
3763
+
3764
+ const originalSessionFile = commandCtx.sessionManager.getSessionFile() ?? "";
3765
+ await pi.commands.get("req-change")!.handler("Adjust docs", commandCtx);
3766
+ const promptText = String(pi.sentUserMessages[0]?.content ?? "");
3767
+ const worktreeMatch = promptText.match(/created worktree-dir `([^`]+)` and prepared context-path `([^`]+)`\./);
3768
+ assert.ok(worktreeMatch, promptText);
3769
+ const worktreeName = worktreeMatch?.[1] ?? "";
3770
+ const executionBasePath = worktreeMatch?.[2] ?? "";
3771
+ const executionSessionFile = commandCtx.sessionManager.getSessionFile() ?? "";
3772
+ assert.notEqual(executionSessionFile, "");
3773
+
3774
+ await pi.emit("before_agent_start", {}, commandCtx);
3775
+ await pi.emit("agent_start", {}, commandCtx);
3776
+ fs.mkdirSync(path.join(executionBasePath, "src"), { recursive: true });
3777
+ fs.writeFileSync(path.join(executionBasePath, "src", "stale-restore.ts"), "export const STALE_RESTORE = 1;\n", "utf8");
3778
+ assert.equal(spawnSync("git", ["add", "src/stale-restore.ts"], { cwd: executionBasePath, encoding: "utf8" }).status, 0);
3779
+ const worktreeCommit = spawnSync("git", ["commit", "-m", "stale restore"], { cwd: executionBasePath, encoding: "utf8" });
3780
+ assert.equal(worktreeCommit.status, 0, worktreeCommit.stderr);
3781
+
3782
+ const persistedContext = readPersistedPromptCommandSessionContext(executionSessionFile) as any;
3783
+ assert.ok(persistedContext);
3784
+ persistedContext.cwd = projectBase;
3785
+ persistedContext.sessionManager.getSessionFile = () => originalSessionFile;
3786
+ persistedContext.sessionManager.getSessionDir = () => path.dirname(originalSessionFile);
3787
+ persistedContext.sessionManager.getCwd = () => projectBase;
3788
+ persistedContext.switchSession = async (sessionPath: string) => {
3789
+ assert.equal(path.resolve(sessionPath), path.resolve(originalSessionFile));
3790
+ process.chdir(projectBase);
3791
+ persistedContext.cwd = projectBase;
3792
+ throw new Error("This extension ctx is stale after session replacement or reload.");
3793
+ };
1566
3794
 
1567
- await command!.handler("", ctx);
3795
+ const eventCtx = createFakeCtx(projectBase);
3796
+ eventCtx.sessionManager.getSessionFile = () => originalSessionFile;
3797
+ eventCtx.sessionManager.getSessionDir = () => path.dirname(originalSessionFile);
3798
+ eventCtx.sessionManager.getCwd = () => projectBase;
3799
+ eventCtx.cwd = projectBase;
3800
+ delete eventCtx.switchSession;
3801
+ process.chdir(projectBase);
1568
3802
 
1569
- const config = JSON.parse(fs.readFileSync(getProjectConfigPath(cwd), "utf8"));
1570
- assert.equal(config["docs-dir"], DEFAULT_DOCS_DIR);
1571
- assert.deepEqual(config["src-dir"], ["src"]);
1572
- assert.equal(config["notify-enabled"], false);
1573
- assert.equal(config["notify-sound"], "none");
3803
+ await pi.emit("agent_end", {
3804
+ messages: [{ role: "assistant", stopReason: "stop", content: [] }],
3805
+ }, eventCtx);
3806
+
3807
+ const debugLog = JSON.parse(fs.readFileSync(path.join(projectBase, "prompt-debug.json"), "utf8"));
3808
+ assert.ok(debugLog.some((entry: any) => entry.action === "workflow_restore" && entry.result?.success === true));
3809
+ assert.ok(debugLog.some((entry: any) => entry.action === "merge" && entry.result?.success === true));
3810
+ assert.equal(process.cwd(), projectBase);
3811
+ assert.equal(eventCtx.cwd, projectBase);
3812
+ assert.equal(fs.existsSync(executionBasePath), false);
3813
+ assert.equal(fs.existsSync(path.join(projectBase, "src", "stale-restore.ts")), true);
3814
+ assert.notEqual(
3815
+ spawnSync("git", ["show-ref", "--verify", "--quiet", `refs/heads/${worktreeName}`], {
3816
+ cwd: projectBase,
3817
+ encoding: "utf8",
3818
+ }).status,
3819
+ 0,
3820
+ );
3821
+ assert.equal(eventCtx.__state.notifications.filter((entry) => entry.level === "error").length, 0);
3822
+ } finally {
3823
+ process.chdir(previousCwd);
3824
+ fs.rmSync(projectBase, { recursive: true, force: true });
3825
+ }
1574
3826
  });
1575
3827
 
1576
- test("configuration menus reuse CLI settings theme semantics", async () => {
1577
- const cwd = createTempDir("pi-usereq-menu-theme-");
1578
- fs.mkdirSync(path.dirname(getProjectConfigPath(cwd)), { recursive: true });
1579
- const usedColors = new Set<string>();
1580
- const pi = createFakePi();
1581
- piUsereqExtension(pi);
1582
- const command = pi.commands.get("pi-usereq");
1583
- assert.ok(command);
1584
- const ctx = createFakeCtx(cwd, { selects: [] }, {
1585
- theme: {
1586
- fg(color: string, text: string) {
1587
- usedColors.add(color);
1588
- return formatFakeThemeForeground(color, text);
1589
- },
1590
- bgFromFg(color: string, text: string) {
1591
- assert.ok(["accent", "muted", "dim"].includes(color));
1592
- return formatFakeThemeBackgroundFromForeground(color, text);
1593
- },
1594
- bold(text: string) {
1595
- return `<bold>${text}</bold>`;
1596
- },
1597
- },
1598
- });
3828
+ test("worktree-backed prompt commands abort before prompt dispatch when post-create verification fails", async () => {
3829
+ const { projectBase } = initFixtureRepo({ fixtures: [] });
3830
+ const previousCwd = process.cwd();
3831
+ const originalExistsSync = fs.existsSync;
3832
+ let failedWorktreeName = "";
3833
+ let failedWorktreePath = "";
3834
+ try {
3835
+ const pi = createFakePi();
3836
+ piUsereqExtension(pi);
3837
+ const ctx = createFakeCtx(projectBase);
3838
+ setPromptCommandPostCreateHookForTests(({ worktreeDir, worktreeRootPath, worktreePath }) => {
3839
+ failedWorktreeName = worktreeDir;
3840
+ failedWorktreePath = worktreePath;
3841
+ fs.existsSync = ((candidatePath: Parameters<typeof fs.existsSync>[0]) => {
3842
+ const normalizedPath = typeof candidatePath === "string" ? candidatePath : String(candidatePath);
3843
+ if (normalizedPath === worktreePath || normalizedPath === worktreeRootPath) {
3844
+ return false;
3845
+ }
3846
+ return originalExistsSync(candidatePath);
3847
+ }) as typeof fs.existsSync;
3848
+ });
1599
3849
 
1600
- await command!.handler("", ctx);
3850
+ await assert.rejects(
3851
+ pi.commands.get("req-change")!.handler("Adjust docs", ctx),
3852
+ /ERROR: Worktree verification failed/,
3853
+ );
1601
3854
 
1602
- const renderedMenu = (ctx.__state.customRenderLines[0] ?? []).join("\n");
1603
- assert.match(renderedMenu, /<accent><bold>pi-usereq<\/bold><\/accent>/);
1604
- assert.match(renderedMenu, /<accent>→ <\/accent>/);
1605
- assert.match(renderedMenu, /<accent>pi-usereq\/docs<\/accent>/);
1606
- assert.match(renderedMenu, /<muted>tests<\/muted>/);
1607
- assert.match(renderedMenu, /<dim>/);
1608
- assert.deepEqual([...usedColors].sort(), ["accent", "dim", "muted", "warning"]);
3855
+ assert.equal(pi.sentUserMessages.length, 0);
3856
+ fs.existsSync = originalExistsSync;
3857
+ assert.equal(fs.existsSync(failedWorktreePath), false);
3858
+ assert.notEqual(failedWorktreeName, "");
3859
+ assert.notEqual(
3860
+ spawnSync("git", ["show-ref", "--verify", "--quiet", `refs/heads/${failedWorktreeName}`], {
3861
+ cwd: projectBase,
3862
+ encoding: "utf8",
3863
+ }).status,
3864
+ 0,
3865
+ );
3866
+ assert.ok(ctx.__state.notifications.some((entry) => /Worktree verification failed/.test(entry.message)));
3867
+ } finally {
3868
+ fs.existsSync = originalExistsSync;
3869
+ setPromptCommandPostCreateHookForTests(undefined);
3870
+ process.chdir(previousCwd);
3871
+ fs.rmSync(projectBase, { recursive: true, force: true });
3872
+ }
1609
3873
  });
1610
3874
 
1611
- test("prompt commands use the current session by default", async () => {
1612
- const cwd = createTempDir("pi-usereq-prompt-current-");
1613
- const pi = createFakePi();
1614
- piUsereqExtension(pi);
1615
- const command = pi.commands.get("req-analyze");
1616
- assert.ok(command);
1617
- const ctx = createFakeCtx(cwd);
3875
+ test("prompt commands remain functional when custom tool registrations are removed from the runtime inventory", async () => {
3876
+ const { projectBase } = initFixtureRepo({ fixtures: [] });
3877
+ try {
3878
+ writeProjectConfigOverrides(projectBase, { GIT_WORKTREE_ENABLED: "disable" });
3879
+ const pi = createFakePi();
3880
+ piUsereqExtension(pi);
3881
+ pi.tools.splice(0, pi.tools.length);
3882
+ const ctx = createFakeCtx(projectBase);
1618
3883
 
1619
- await command!.handler("Inspect src/index.ts for prompt coverage", ctx);
3884
+ await pi.commands.get("req-analyze")!.handler("Inspect src/index.ts", ctx);
1620
3885
 
1621
- assert.equal(ctx.__state.waitForIdleCalls, 0);
1622
- assert.equal(ctx.__state.newSessions.length, 0);
1623
- assert.equal(pi.sentUserMessages.length, 1);
1624
- assert.match(String(pi.sentUserMessages[0]?.content), /Inspect src\/index\.ts for prompt coverage/);
3886
+ assert.equal(pi.sentUserMessages.length, 1);
3887
+ assert.match(String(pi.sentUserMessages[0]?.content ?? ""), /Inspect src\/index\.ts/);
3888
+ } finally {
3889
+ fs.rmSync(projectBase, { recursive: true, force: true });
3890
+ }
1625
3891
  });
1626
3892
 
1627
3893
  test("session_start applies configured pi-usereq startup tools", async () => {
@@ -1634,7 +3900,7 @@ test("session_start applies configured pi-usereq startup tools", async () => {
1634
3900
  "tests-dir": "tests",
1635
3901
  "src-dir": ["src", "foobar"],
1636
3902
  "static-check": {},
1637
- "enabled-tools": ["git-path", "static-check"],
3903
+ "enabled-tools": ["files-tokens", "static-check"],
1638
3904
  }, null, 2)}\n`,
1639
3905
  "utf8",
1640
3906
  );
@@ -1645,7 +3911,7 @@ test("session_start applies configured pi-usereq startup tools", async () => {
1645
3911
  await pi.emit("session_start", { reason: "startup" }, ctx);
1646
3912
 
1647
3913
  const activeTools = new Set(pi.getActiveTools());
1648
- assert.deepEqual([...activeTools].sort(), ["git-path", "static-check"]);
3914
+ assert.deepEqual([...activeTools].sort(), ["files-tokens", "static-check"]);
1649
3915
  assert.equal(
1650
3916
  ctx.__state.statuses.get("pi-usereq"),
1651
3917
  buildExpectedFakeStatusText({
@@ -1684,6 +3950,33 @@ test("session_start renders the 0-percent context icon when context usage is emp
1684
3950
  );
1685
3951
  });
1686
3952
 
3953
+ /**
3954
+ * @brief Verifies stale replacement contexts do not break workflow-state rendering.
3955
+ * @details Simulates the guarded pi runtime error thrown when an extension context is accessed after session replacement or reload. The test proves workflow-state updates must preserve internal state while suppressing stale-context render failures instead of surfacing extension errors.
3956
+ * @return {void} No return value.
3957
+ * @throws {AssertionError} Throws when stale-context rendering escapes or leaves the stale context cached for later renders.
3958
+ * @satisfies REQ-280, TST-092
3959
+ */
3960
+ test("stale replacement contexts do not break workflow-state rendering", () => {
3961
+ const cwd = createTempDir("pi-usereq-stale-render-");
3962
+ fs.mkdirSync(path.dirname(getProjectConfigPath(cwd)), { recursive: true });
3963
+ const controller = createPiUsereqStatusController();
3964
+ setPiUsereqStatusConfig(controller, getDefaultConfig());
3965
+ const staleCtx = createFakeCtx(cwd);
3966
+ Object.defineProperty(staleCtx, "ui", {
3967
+ configurable: true,
3968
+ get() {
3969
+ throw new Error("This extension instance is stale after session replacement or reload. Use the provided replacement-session context instead.");
3970
+ },
3971
+ });
3972
+
3973
+ assert.doesNotThrow(() => {
3974
+ setPiUsereqWorkflowState(controller, "running", staleCtx);
3975
+ });
3976
+ assert.equal(controller.state.workflowState, "running");
3977
+ assert.equal(controller.latestContext, undefined);
3978
+ });
3979
+
1687
3980
  test("extension registers wrappers for all pi-usereq status hooks", () => {
1688
3981
  const pi = createFakePi();
1689
3982
  piUsereqExtension(pi);
@@ -1924,6 +4217,71 @@ test("agent timing status updates et while preserving accumulated completed runt
1924
4217
  }
1925
4218
  });
1926
4219
 
4220
+ test("elapsed counters persist across new-session rebinding and reset on reload", async () => {
4221
+ const cwd = createTempDir("pi-usereq-timing-rebind-");
4222
+ fs.mkdirSync(path.dirname(getProjectConfigPath(cwd)), { recursive: true });
4223
+ const originalDateNow = Date.now;
4224
+ let nowMs = 0;
4225
+ Date.now = () => nowMs;
4226
+
4227
+ try {
4228
+ const firstPi = createFakePi();
4229
+ piUsereqExtension(firstPi);
4230
+ const firstCtx = createFakeCtx(cwd);
4231
+ await firstPi.emit("session_start", { reason: "startup" }, firstCtx);
4232
+ await firstPi.emit("agent_start", {}, firstCtx);
4233
+ nowMs = 61_000;
4234
+ await firstPi.emit("agent_end", {
4235
+ messages: [
4236
+ {
4237
+ role: "assistant",
4238
+ stopReason: "stop",
4239
+ content: [],
4240
+ },
4241
+ ],
4242
+ }, firstCtx);
4243
+ await firstPi.emit("session_shutdown", {}, firstCtx);
4244
+
4245
+ const newPi = createFakePi();
4246
+ piUsereqExtension(newPi);
4247
+ const newCtx = createFakeCtx(cwd);
4248
+ await newPi.emit("session_start", {
4249
+ reason: "new",
4250
+ previousSessionFile: "/tmp/previous-session.jsonl",
4251
+ }, newCtx);
4252
+ assert.equal(
4253
+ newCtx.__state.statuses.get("pi-usereq"),
4254
+ buildExpectedFakeStatusText({
4255
+ basePath: buildExpectedFakeBasePath(cwd),
4256
+ docsDir: DEFAULT_DOCS_DIR,
4257
+ testsDir: "tests",
4258
+ srcDir: ["src"],
4259
+ contextFilledCells: 0,
4260
+ et: "⏱︎ --:-- ⚑ 1:01 ⌛︎1:01",
4261
+ }),
4262
+ );
4263
+ await newPi.emit("session_shutdown", {}, newCtx);
4264
+
4265
+ const reloadPi = createFakePi();
4266
+ piUsereqExtension(reloadPi);
4267
+ const reloadCtx = createFakeCtx(cwd);
4268
+ await reloadPi.emit("session_start", { reason: "reload" }, reloadCtx);
4269
+ assert.equal(
4270
+ reloadCtx.__state.statuses.get("pi-usereq"),
4271
+ buildExpectedFakeStatusText({
4272
+ basePath: buildExpectedFakeBasePath(cwd),
4273
+ docsDir: DEFAULT_DOCS_DIR,
4274
+ testsDir: "tests",
4275
+ srcDir: ["src"],
4276
+ contextFilledCells: 0,
4277
+ et: "⏱︎ --:-- ⚑ --:-- ⌛︎--:--",
4278
+ }),
4279
+ );
4280
+ } finally {
4281
+ Date.now = originalDateNow;
4282
+ }
4283
+ });
4284
+
1927
4285
  test("session_start enables default custom tools and default embedded tools only", async () => {
1928
4286
  const cwd = createTempDir("pi-usereq-default-tools-");
1929
4287
  fs.mkdirSync(path.dirname(getProjectConfigPath(cwd)), { recursive: true });
@@ -1936,14 +4294,67 @@ test("session_start enables default custom tools and default embedded tools only
1936
4294
  for (const toolName of PI_USEREQ_DEFAULT_ENABLED_TOOL_NAMES) {
1937
4295
  assert.ok(activeTools.has(toolName), `missing default active tool ${toolName}`);
1938
4296
  }
4297
+ assert.equal(activeTools.has("search"), true);
4298
+ assert.equal(activeTools.has("files-search"), true);
1939
4299
  assert.equal(activeTools.has("find"), false);
1940
4300
  assert.equal(activeTools.has("grep"), false);
1941
4301
  assert.equal(activeTools.has("ls"), false);
1942
4302
  });
1943
4303
 
1944
- test("default configuration applies the documented notify, sound, and pushover defaults", () => {
4304
+ test("default configuration applies the documented static-check, debug, notify, sound, and pushover defaults", () => {
1945
4305
  const config = getDefaultConfig(createTempDir("pi-usereq-default-notify-"));
1946
4306
 
4307
+ assert.equal(config.AUTO_GIT_COMMIT, "enable");
4308
+ assert.deepEqual(Object.keys(config["static-check"]).length, 20);
4309
+ assert.deepEqual(config["static-check"].C, createStaticCheckLanguageConfig([
4310
+ {
4311
+ module: "Command",
4312
+ cmd: "cppcheck",
4313
+ params: [
4314
+ "--error-exitcode=1",
4315
+ "--enable=warning,style,performance,portability",
4316
+ "--std=c11",
4317
+ ],
4318
+ },
4319
+ {
4320
+ module: "Command",
4321
+ cmd: "clang-format",
4322
+ params: ["--dry-run", "--Werror"],
4323
+ },
4324
+ ], "enable"));
4325
+ assert.deepEqual(config["static-check"]["C++"], createStaticCheckLanguageConfig([
4326
+ {
4327
+ module: "Command",
4328
+ cmd: "cppcheck",
4329
+ params: [
4330
+ "--error-exitcode=1",
4331
+ "--enable=warning,style,performance,portability",
4332
+ "--std=c++20",
4333
+ ],
4334
+ },
4335
+ {
4336
+ module: "Command",
4337
+ cmd: "clang-format",
4338
+ params: ["--dry-run", "--Werror"],
4339
+ },
4340
+ ], "enable"));
4341
+ assert.deepEqual(config["static-check"].Python, createStaticCheckLanguageConfig([
4342
+ { module: "Command", cmd: "pyright", params: ["--outputjson"] },
4343
+ { module: "Command", cmd: "ruff", params: ["check"] },
4344
+ ], "enable"));
4345
+ assert.deepEqual(config["static-check"].JavaScript, createStaticCheckLanguageConfig([
4346
+ { module: "Command", cmd: "node", params: ["--check"] },
4347
+ ], "enable"));
4348
+ assert.deepEqual(config["static-check"].TypeScript, createStaticCheckLanguageConfig([
4349
+ { module: "Command", cmd: "npx", params: ["eslint"] },
4350
+ ], "enable"));
4351
+ assert.deepEqual(config["static-check"].Ruby, createStaticCheckLanguageConfig([], "disable"));
4352
+ assert.equal(config.DEBUG_ENABLED, "disable");
4353
+ assert.equal(config.DEBUG_LOG_FILE, DEFAULT_DEBUG_LOG_FILE);
4354
+ assert.equal(config.DEBUG_WORKFLOW_EVENTS, DEFAULT_DEBUG_WORKFLOW_EVENTS);
4355
+ assert.equal(config.DEBUG_LOG_ON_STATUS, DEFAULT_DEBUG_LOG_ON_STATUS);
4356
+ assert.deepEqual(config.DEBUG_ENABLED_TOOLS, []);
4357
+ assert.deepEqual(config.DEBUG_ENABLED_PROMPTS, []);
1947
4358
  assert.equal(config["notify-enabled"], false);
1948
4359
  assert.equal(config["notify-on-completed"], true);
1949
4360
  assert.equal(config["notify-on-interrupted"], false);
@@ -1960,22 +4371,40 @@ test("default configuration applies the documented notify, sound, and pushover d
1960
4371
  assert.equal(config["notify-pushover-text"], "%%RESULT%%\n%%ARGS%%");
1961
4372
  });
1962
4373
 
1963
- test("configuration menu can enable embedded builtin tools", async () => {
4374
+ test("configuration menu orders tool toggles and can enable embedded builtin tools", async () => {
1964
4375
  const cwd = createTempDir("pi-usereq-menu-embedded-");
1965
4376
  fs.mkdirSync(path.dirname(getProjectConfigPath(cwd)), { recursive: true });
1966
4377
  const pi = createFakePi();
1967
4378
  piUsereqExtension(pi);
1968
4379
  const command = pi.commands.get("pi-usereq");
1969
4380
  assert.ok(command);
4381
+ const ctx = createFakeCtx(cwd, {
4382
+ selects: ["Enable tools", "Enable tools", "grep", "Save and close", "Save and close"],
4383
+ });
1970
4384
 
1971
- await command!.handler(
1972
- "",
1973
- createFakeCtx(cwd, {
1974
- selects: ["Enable tools", "Enable tools", "grep", "Save and close", "Save and close"],
1975
- }),
1976
- );
4385
+ await command!.handler("", ctx);
1977
4386
 
1978
4387
  const config = JSON.parse(fs.readFileSync(getProjectConfigPath(cwd), "utf8"));
4388
+ assert.deepEqual(ctx.__state.selectCalls[2]?.items ?? [], [
4389
+ "compress",
4390
+ "references",
4391
+ "search",
4392
+ "static-check",
4393
+ "tokens",
4394
+ "files-compress",
4395
+ "files-references",
4396
+ "files-search",
4397
+ "files-static-check",
4398
+ "files-tokens",
4399
+ "bash",
4400
+ "edit",
4401
+ "read",
4402
+ "write",
4403
+ "find",
4404
+ "grep",
4405
+ "ls",
4406
+ "Reset defaults",
4407
+ ]);
1979
4408
  assert.ok(config["enabled-tools"].includes("grep"));
1980
4409
  assert.ok(pi.getActiveTools().includes("grep"));
1981
4410
  });
@@ -2076,6 +4505,8 @@ test("configuration menu can persist pushover settings", async () => {
2076
4505
  const ctx = createFakeCtx(cwd, {
2077
4506
  selects: [
2078
4507
  "notifications",
4508
+ "Pushover User Key/Delivery Group Key",
4509
+ "Pushover Token/API Token Key",
2079
4510
  "Enable pushover",
2080
4511
  "Pushover events",
2081
4512
  "Prompt failed",
@@ -2084,16 +4515,14 @@ test("configuration menu can persist pushover settings", async () => {
2084
4515
  "High",
2085
4516
  "Pushover title",
2086
4517
  "Pushover text",
2087
- "Pushover User Key/Delivery Group Key",
2088
- "Pushover Token/API Token Key",
2089
4518
  "Save and close",
2090
4519
  "Save and close",
2091
4520
  ],
2092
4521
  inputs: [
2093
- "%%PROMT%% @ %%BASE%% [%%TIME%%]",
2094
- "%%RESULT%%\n%%ARGS%%",
2095
4522
  "gzfjjvp1xxmhibqwzh9m7i1zwvf83j",
2096
4523
  "ah6bf5u2sj63mcvou6qamiabeoubbe",
4524
+ "%%PROMT%% @ %%BASE%% [%%TIME%%]",
4525
+ "%%RESULT%%\\n%%ARGS%%",
2097
4526
  ],
2098
4527
  });
2099
4528
 
@@ -2123,16 +4552,40 @@ test("configuration menu can persist pushover settings", async () => {
2123
4552
  );
2124
4553
  });
2125
4554
 
2126
- test("prompt-end pushover requests honor global enable, event toggles, credentials, priority, and templates", async () => {
2127
- const cwd = createTempDir("pi-usereq-pushover-run-");
4555
+ test("notifications menu escapes pushover text and locks pushover enablement until credentials exist", async () => {
4556
+ const cwd = createTempDir("pi-usereq-menu-pushover-display-");
2128
4557
  fs.mkdirSync(path.dirname(getProjectConfigPath(cwd)), { recursive: true });
2129
- const configPath = getProjectConfigPath(cwd);
2130
- const writeConfig = (overrides: Record<string, unknown>) => {
2131
- const config = {
4558
+ fs.writeFileSync(
4559
+ getProjectConfigPath(cwd),
4560
+ `${JSON.stringify({
2132
4561
  ...getDefaultConfig(cwd),
4562
+ "notify-pushover-text": "first line\nsecond line",
4563
+ }, null, 2)}\n`,
4564
+ "utf8",
4565
+ );
4566
+ const pi = createFakePi();
4567
+ piUsereqExtension(pi);
4568
+ const command = pi.commands.get("pi-usereq");
4569
+ assert.ok(command);
4570
+ const ctx = createFakeCtx(cwd, {
4571
+ selects: ["notifications", "Pushover text", "Save and close", "Save and close"],
4572
+ });
4573
+
4574
+ await command!.handler("", ctx);
4575
+
4576
+ const renderedNotificationsMenu = (ctx.__state.customRenderLines[2] ?? []).join("\n");
4577
+ assert.match(renderedNotificationsMenu, /first line\\nsecond line/);
4578
+ assert.match(renderedNotificationsMenu, /<dim>Enable pushover/);
4579
+ assert.match(renderedNotificationsMenu, /<dim>off<\/dim>/);
4580
+ });
4581
+
4582
+ test("prompt-end pushover requests honor global enable, event toggles, credentials, priority, and templates", async () => {
4583
+ const { projectBase: cwd } = initFixtureRepo({ fixtures: [] });
4584
+ const writeConfig = (overrides: Record<string, unknown>) => {
4585
+ writeProjectConfigOverrides(cwd, {
4586
+ GIT_WORKTREE_ENABLED: "disable",
2133
4587
  ...overrides,
2134
- };
2135
- fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8");
4588
+ });
2136
4589
  };
2137
4590
  const pi = createFakePi();
2138
4591
  piUsereqExtension(pi);
@@ -2308,12 +4761,12 @@ test("prompt-end pushover requests honor global enable, event toggles, credentia
2308
4761
  } finally {
2309
4762
  setPiNotifyHttpsRequestForTests(undefined);
2310
4763
  Date.now = originalDateNow;
4764
+ fs.rmSync(cwd, { recursive: true, force: true });
2311
4765
  }
2312
4766
  });
2313
4767
 
2314
4768
  test("pushover global enable controls status and suppresses delivery when disabled", async () => {
2315
- const cwd = createTempDir("pi-usereq-pushover-disabled-");
2316
- fs.mkdirSync(path.dirname(getProjectConfigPath(cwd)), { recursive: true });
4769
+ const { projectBase: cwd } = initFixtureRepo({ fixtures: [] });
2317
4770
  const pi = createFakePi();
2318
4771
  piUsereqExtension(pi);
2319
4772
  const command = pi.commands.get("pi-usereq");
@@ -2333,6 +4786,7 @@ test("pushover global enable controls status and suppresses delivery when disabl
2333
4786
  });
2334
4787
 
2335
4788
  await command!.handler("", ctx);
4789
+ writeProjectConfigOverrides(cwd, { GIT_WORKTREE_ENABLED: "disable" });
2336
4790
 
2337
4791
  const config = JSON.parse(fs.readFileSync(getProjectConfigPath(cwd), "utf8"));
2338
4792
  assert.equal(config["notify-pushover-enabled"], false);
@@ -2390,19 +4844,17 @@ test("pushover global enable controls status and suppresses delivery when disabl
2390
4844
  assert.deepEqual(recordedRequests, []);
2391
4845
  } finally {
2392
4846
  setPiNotifyHttpsRequestForTests(undefined);
4847
+ fs.rmSync(cwd, { recursive: true, force: true });
2393
4848
  }
2394
4849
  });
2395
4850
 
2396
4851
  test("command notify routes through PI_NOTIFY_CMD placeholders and per-event toggles", async () => {
2397
- const cwd = createTempDir("pi-usereq-command-notify-");
2398
- fs.mkdirSync(path.dirname(getProjectConfigPath(cwd)), { recursive: true });
2399
- const configPath = getProjectConfigPath(cwd);
4852
+ const { projectBase: cwd } = initFixtureRepo({ fixtures: [] });
2400
4853
  const writeConfig = (overrides: Record<string, unknown>) => {
2401
- const config = {
2402
- ...getDefaultConfig(cwd),
4854
+ writeProjectConfigOverrides(cwd, {
4855
+ GIT_WORKTREE_ENABLED: "disable",
2403
4856
  ...overrides,
2404
- };
2405
- fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8");
4857
+ });
2406
4858
  };
2407
4859
  const pi = createFakePi();
2408
4860
  piUsereqExtension(pi);
@@ -2502,19 +4954,17 @@ test("command notify routes through PI_NOTIFY_CMD placeholders and per-event tog
2502
4954
  } finally {
2503
4955
  setPiNotifySpawnForTests(undefined);
2504
4956
  Date.now = originalDateNow;
4957
+ fs.rmSync(cwd, { recursive: true, force: true });
2505
4958
  }
2506
4959
  });
2507
4960
 
2508
4961
  test("sound routing honors the selected sound state and per-event toggles", async () => {
2509
- const cwd = createTempDir("pi-usereq-sound-routing-");
2510
- fs.mkdirSync(path.dirname(getProjectConfigPath(cwd)), { recursive: true });
2511
- const configPath = getProjectConfigPath(cwd);
4962
+ const { projectBase: cwd } = initFixtureRepo({ fixtures: [] });
2512
4963
  const writeConfig = (overrides: Record<string, unknown>) => {
2513
- const config = {
2514
- ...getDefaultConfig(cwd),
4964
+ writeProjectConfigOverrides(cwd, {
4965
+ GIT_WORKTREE_ENABLED: "disable",
2515
4966
  ...overrides,
2516
- };
2517
- fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8");
4967
+ });
2518
4968
  };
2519
4969
  const pi = createFakePi();
2520
4970
  piUsereqExtension(pi);
@@ -2585,6 +5035,7 @@ test("sound routing honors the selected sound state and per-event toggles", asyn
2585
5035
  assert.equal(recordedCommands.length, 1);
2586
5036
  } finally {
2587
5037
  setPiNotifySpawnForTests(undefined);
5038
+ fs.rmSync(cwd, { recursive: true, force: true });
2588
5039
  }
2589
5040
  });
2590
5041
 
@@ -2624,28 +5075,6 @@ test("sound toggle shortcut cycles persisted pi-notify sound levels", async () =
2624
5075
  }
2625
5076
  });
2626
5077
 
2627
- test("git-path tool derives the repository root at runtime", async () => {
2628
- const { projectBase } = initFixtureRepo();
2629
- try {
2630
- const configPath = getProjectConfigPath(projectBase);
2631
- const config = JSON.parse(fs.readFileSync(configPath, "utf8"));
2632
- config["git-path"] = "/tmp/wrong-git-root";
2633
- fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8");
2634
-
2635
- const pi = createFakePi();
2636
- piUsereqExtension(pi);
2637
- const result = await executeRegisteredTool(pi, "git-path", projectBase) as {
2638
- content?: Array<{ text?: string }>;
2639
- details?: { result: { path_value: string } };
2640
- };
2641
-
2642
- assert.deepEqual(JSON.parse(result.content?.[0]?.text ?? "{}"), JSON.parse(JSON.stringify(result.details)));
2643
- assert.equal(result.details?.result.path_value, projectBase);
2644
- } finally {
2645
- fs.rmSync(projectBase, { recursive: true, force: true });
2646
- }
2647
- });
2648
-
2649
5078
  test("static-check exposes the supported programming languages", () => {
2650
5079
  const supported = getSupportedStaticCheckLanguageSupport();
2651
5080
  assert.equal(supported.length, 20);
@@ -2670,8 +5099,15 @@ test("configuration menu omits removed static-check raw-spec and reference actio
2670
5099
  await command!.handler("", ctx);
2671
5100
 
2672
5101
  const renderedStaticCheckMenu = (ctx.__state.customRenderLines[1] ?? []).join("\n");
5102
+ const staticCheckItems = ctx.__state.selectCalls[1]?.items ?? [];
2673
5103
  assert.match(renderedStaticCheckMenu, /Add static code checker/);
2674
5104
  assert.match(renderedStaticCheckMenu, /Remove static code checker/);
5105
+ assert.deepEqual(staticCheckItems.slice(0, 2), ["Add static code checker", "Remove static code checker"]);
5106
+ assert.deepEqual(
5107
+ staticCheckItems.slice(2, 22),
5108
+ getSupportedStaticCheckLanguageSupport().map((entry) => entry.language),
5109
+ );
5110
+ assert.deepEqual(staticCheckItems.slice(-1), ["Reset defaults"]);
2675
5111
  assert.doesNotMatch(renderedStaticCheckMenu, /Add entry from LANG=MODULE\[,CMD\[,PARAM\.\.\.\]\]/);
2676
5112
  assert.doesNotMatch(renderedStaticCheckMenu, /Show supported languages/);
2677
5113
  });
@@ -2696,6 +5132,29 @@ test("configuration menu hides removed static-check modules from user-facing act
2696
5132
  assert.doesNotMatch(renderedStaticCheckMenu, /Dummy/);
2697
5133
  });
2698
5134
 
5135
+ test("configuration menu can toggle per-language static-check enablement", async () => {
5136
+ const cwd = createTempDir("pi-usereq-menu-sc-toggle-");
5137
+ fs.mkdirSync(path.dirname(getProjectConfigPath(cwd)), { recursive: true });
5138
+ const pi = createFakePi();
5139
+ piUsereqExtension(pi);
5140
+ const command = pi.commands.get("pi-usereq");
5141
+ assert.ok(command);
5142
+
5143
+ await command!.handler(
5144
+ "",
5145
+ createFakeCtx(cwd, {
5146
+ selects: ["static-check", "Python", "Save and close", "Save and close"],
5147
+ }),
5148
+ );
5149
+
5150
+ const config = JSON.parse(fs.readFileSync(getProjectConfigPath(cwd), "utf8"));
5151
+ assert.equal(config["static-check"].Python.enabled, "disable");
5152
+ assert.deepEqual(config["static-check"].Python.checkers, [
5153
+ { module: "Command", cmd: "pyright", params: ["--outputjson"] },
5154
+ { module: "Command", cmd: "ruff", params: ["check"] },
5155
+ ]);
5156
+ });
5157
+
2699
5158
  test("configuration menu can add guided static-check entries for explicit supported languages", async () => {
2700
5159
  const cwd = createTempDir("pi-usereq-menu-sc-guided-");
2701
5160
  fs.mkdirSync(path.dirname(getProjectConfigPath(cwd)), { recursive: true });
@@ -2710,7 +5169,7 @@ test("configuration menu can add guided static-check entries for explicit suppor
2710
5169
  selects: [
2711
5170
  "static-check",
2712
5171
  "Add static code checker",
2713
- "Python",
5172
+ "Ruby",
2714
5173
  "Save and close",
2715
5174
  "Save and close",
2716
5175
  ],
@@ -2719,5 +5178,7 @@ test("configuration menu can add guided static-check entries for explicit suppor
2719
5178
  );
2720
5179
 
2721
5180
  const config = JSON.parse(fs.readFileSync(getProjectConfigPath(cwd), "utf8"));
2722
- assert.deepEqual(config["static-check"].Python, [{ module: "Command", cmd: "true" }]);
5181
+ assert.deepEqual(config["static-check"].Ruby, createStaticCheckLanguageConfig([
5182
+ { module: "Command", cmd: "true" },
5183
+ ], "enable"));
2723
5184
  });