pi-usereq 0.7.0 → 0.11.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 (773) hide show
  1. package/.gitignore +2 -0
  2. package/CHANGELOG.md +24 -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 +5214 -24470
  21. package/pi-usereq/docs/REQUIREMENTS.md +307 -170
  22. package/pi-usereq/docs/WORKFLOW.md +334 -316
  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 +314 -59
  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 +1816 -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/runtime-project-paths.ts +21 -1
  713. package/src/core/settings-menu.ts +12 -6
  714. package/src/core/source-analyzer.ts +27 -7
  715. package/src/core/token-counter.ts +6 -76
  716. package/src/core/tool-runner.ts +38 -317
  717. package/src/index.ts +2047 -947
  718. package/src/resources/instructions/git_commit.md +16 -0
  719. package/src/resources/instructions/git_read-only.md +2 -0
  720. package/src/resources/prompts/analyze.md +12 -13
  721. package/src/resources/prompts/change.md +21 -59
  722. package/src/resources/prompts/check.md +14 -16
  723. package/src/resources/prompts/cover.md +21 -59
  724. package/src/resources/prompts/create.md +5 -4
  725. package/src/resources/prompts/fix.md +20 -58
  726. package/src/resources/prompts/flowchart.md +14 -49
  727. package/src/resources/prompts/implement.md +14 -50
  728. package/src/resources/prompts/new.md +20 -58
  729. package/src/resources/prompts/readme.md +14 -50
  730. package/src/resources/prompts/recreate.md +14 -45
  731. package/src/resources/prompts/refactor.md +20 -58
  732. package/src/resources/prompts/references.md +7 -43
  733. package/src/resources/prompts/renumber.md +8 -39
  734. package/src/resources/prompts/workflow.md +14 -50
  735. package/src/resources/prompts/write.md +5 -4
  736. package/tests/attended-results-scenarios.ts +30 -136
  737. package/tests/cli-command-option-parity.test.ts +148 -192
  738. package/tests/debug-extension-harness.test.ts +77 -70
  739. package/tests/extension-registration.test.ts +3205 -658
  740. package/tests/fixtures_attended_results/project/compress-line-numbers.json +1 -1
  741. package/tests/fixtures_attended_results/project/compress.json +1 -1
  742. package/tests/fixtures_attended_results/project/find-line-numbers.json +1 -1
  743. package/tests/fixtures_attended_results/project/find.json +1 -1
  744. package/tests/fixtures_attended_results/project/references.json +2 -2
  745. package/tests/fixtures_attended_results/standalone/files-compress/fixture_go.go.json +1 -1
  746. package/tests/fixtures_attended_results/standalone/files-compress-line-numbers/fixture_go.go.json +1 -1
  747. package/tests/fixtures_attended_results/standalone/files-find/fixture_go.go.json +1 -1
  748. package/tests/fixtures_attended_results/standalone/files-find-line-numbers/fixture_go.go.json +1 -1
  749. package/tests/fixtures_attended_results/standalone/files-references/fixture_c.c.json +1 -1
  750. package/tests/fixtures_attended_results/standalone/files-references/fixture_cpp.cpp.json +1 -1
  751. package/tests/fixtures_attended_results/standalone/files-references/fixture_csharp.cs.json +1 -1
  752. package/tests/fixtures_attended_results/standalone/files-references/fixture_elixir.ex.json +1 -1
  753. package/tests/fixtures_attended_results/standalone/files-references/fixture_go.go.json +1 -1
  754. package/tests/fixtures_attended_results/standalone/files-references/fixture_haskell.hs.json +1 -1
  755. package/tests/fixtures_attended_results/standalone/files-references/fixture_java.java.json +1 -1
  756. package/tests/fixtures_attended_results/standalone/files-references/fixture_javascript.js.json +1 -1
  757. package/tests/fixtures_attended_results/standalone/files-references/fixture_kotlin.kt.json +1 -1
  758. package/tests/fixtures_attended_results/standalone/files-references/fixture_lua.lua.json +1 -1
  759. package/tests/fixtures_attended_results/standalone/files-references/fixture_perl.pl.json +1 -1
  760. package/tests/fixtures_attended_results/standalone/files-references/fixture_php.php.json +1 -1
  761. package/tests/fixtures_attended_results/standalone/files-references/fixture_python.py.json +1 -1
  762. package/tests/fixtures_attended_results/standalone/files-references/fixture_ruby.rb.json +1 -1
  763. package/tests/fixtures_attended_results/standalone/files-references/fixture_rust.rs.json +1 -1
  764. package/tests/fixtures_attended_results/standalone/files-references/fixture_scala.scala.json +1 -1
  765. package/tests/fixtures_attended_results/standalone/files-references/fixture_shell.sh.json +1 -1
  766. package/tests/fixtures_attended_results/standalone/files-references/fixture_swift.swift.json +1 -1
  767. package/tests/fixtures_attended_results/standalone/files-references/fixture_typescript.ts.json +1 -1
  768. package/tests/fixtures_attended_results/standalone/files-references/fixture_zig.zig.json +1 -1
  769. package/tests/helpers.ts +24 -15
  770. package/tests/oracle-project.test.ts +29 -30
  771. package/tests/oracle-standalone.test.ts +27 -1
  772. package/tests/prompt-rendering.test.ts +40 -9
  773. 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
 
@@ -294,11 +406,30 @@ function formatFakeThemeBackgroundFromForeground(color: string, text: string): s
294
406
  return `<bg-from-fg-${color}>${text}</bg-from-fg-${color}>`;
295
407
  }
296
408
 
409
+ /**
410
+ * @brief Resolves the expected fake branch payload for status assertions.
411
+ * @details Reads `git branch --show-current` from the supplied base path and falls back to `unknown` when the path is outside a repository or HEAD has no branch name. Runtime is dominated by git execution. Side effects include subprocess creation.
412
+ * @param[in] basePath {string | undefined} Base path represented by the fake context.
413
+ * @return {string} Expected branch name or `unknown` when unavailable.
414
+ */
415
+ function buildExpectedFakeBranchValue(basePath: string | undefined): string {
416
+ if (!basePath || !fs.existsSync(basePath)) {
417
+ return "unknown";
418
+ }
419
+ const result = spawnSync("git", ["branch", "--show-current"], {
420
+ cwd: basePath,
421
+ encoding: "utf8",
422
+ });
423
+ if (result.error || result.status !== 0) {
424
+ return "unknown";
425
+ }
426
+ const branchName = result.stdout.trim();
427
+ return branchName === "" ? "unknown" : branchName;
428
+ }
429
+
297
430
  /**
298
431
  * @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.
432
+ * @details Resolves the documented icon thresholds for `0`, `>0-<25`, `>=25-<50`, `>=50-<75`, and `>=75-<90`, emits the non-blinking error full icon for `>=90-<100`, and emits the blinking error full icon for `>=100`. Runtime is O(1). No external state is mutated.
302
433
  * @param[in] options {{ filledCells: number; percent?: number | null }} Expected context-gauge facts.
303
434
  * @return {string} Encoded context-gauge string.
304
435
  */
@@ -308,34 +439,35 @@ function buildExpectedFakeContextBar(options: {
308
439
  }): string {
309
440
  const percent = options.percent;
310
441
  if (percent === undefined || percent === null || percent <= 0) {
311
- return formatFakeThemeForeground("warning", "▕_▏");
442
+ return "▕_▏";
312
443
  }
313
- if (percent > 100) {
444
+ if (percent >= 100) {
314
445
  return formatFakeThemeForeground("error", "\u001b[5m▕█▏\u001b[25m");
315
446
  }
316
- if (percent <= 25) {
317
- return formatFakeThemeForeground("warning", "▕▂▏");
447
+ if (percent >= 90) {
448
+ return formatFakeThemeForeground("error", "▕█▏");
318
449
  }
319
- if (percent <= 50) {
320
- return formatFakeThemeForeground("warning", "▕▄▏");
450
+ if (percent < 25) {
451
+ return "▕▂▏";
321
452
  }
322
- if (percent <= 90) {
323
- return formatFakeThemeForeground("warning", "▕▆▏");
453
+ if (percent < 50) {
454
+ return "▕▄▏";
324
455
  }
325
- return formatFakeThemeForeground("warning", "▕█▏");
456
+ if (percent < 75) {
457
+ return "▕▆▏";
458
+ }
459
+ return "▕█▏";
326
460
  }
327
461
 
328
462
  /**
329
463
  * @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.
464
+ * @details Reconstructs the field order, workflow-state highlighting, branch field, icon-based context gauge, consolidated elapsed field, and sound field emitted by the extension using deterministic fake theme markers. Runtime is O(1) plus optional git execution for branch discovery. No external state is mutated.
465
+ * @param[in] options {{ workflowState?: string; basePath?: string; contextFilledCells: number; contextPercent?: number | null; et: string; sound?: string }} Expected status facts for rendered `status`, `branch`, `context`, `elapsed`, and `sound` fields.
335
466
  * @return {string} Encoded status-bar string.
336
467
  */
337
468
  function buildExpectedFakeStatusText(options: {
338
- basePath: string;
469
+ workflowState?: string;
470
+ basePath?: string;
339
471
  docsDir?: string;
340
472
  testsDir?: string;
341
473
  srcDir?: string[];
@@ -346,12 +478,18 @@ function buildExpectedFakeStatusText(options: {
346
478
  }): string {
347
479
  const buildField = (fieldName: string, value: string): string =>
348
480
  `${formatFakeThemeForeground("accent", `${fieldName}:`)}${formatFakeThemeForeground("warning", value)}`;
481
+ const workflowStateText = options.workflowState ?? "idle";
482
+ const workflowStateValue = workflowStateText === "error"
483
+ ? formatFakeThemeForeground("error", "\u001b[5merror\u001b[25m")
484
+ : formatFakeThemeForeground("warning", workflowStateText);
485
+ const branchValue = buildExpectedFakeBranchValue(options.basePath);
349
486
  const contextBar = buildExpectedFakeContextBar({
350
487
  filledCells: options.contextFilledCells,
351
488
  percent: options.contextPercent,
352
489
  });
353
490
  return [
354
- buildField("base", options.basePath),
491
+ `${formatFakeThemeForeground("accent", "status:")}${workflowStateValue}`,
492
+ buildField("branch", branchValue),
355
493
  `${formatFakeThemeForeground("accent", "context:")}${contextBar}`,
356
494
  buildField("elapsed", options.et),
357
495
  buildField("sound", options.sound ?? "none"),
@@ -359,10 +497,11 @@ function buildExpectedFakeStatusText(options: {
359
497
  }
360
498
 
361
499
  /**
362
- * @brief Formats one expected absolute base path for fake status assertions.
500
+ * @brief Formats one expected absolute base path for prompt and status assertions.
363
501
  * @details Resolves the supplied cwd and normalizes path separators to `/` so
364
- * fake status comparisons stay stable across operating systems. Runtime is O(p)
365
- * in path length. No external state is mutated.
502
+ * prompt-text comparisons and legacy status-call-site inputs stay stable across
503
+ * operating systems. Runtime is O(p) in path length. No external state is
504
+ * mutated.
366
505
  * @param[in] cwd {string} Runtime working directory.
367
506
  * @return {string} Slash-normalized absolute base path.
368
507
  */
@@ -396,8 +535,8 @@ function buildExpectedNotifyBasePath(cwd: string): string {
396
535
  * @return {string} `~`-relative or absolute config-path display string.
397
536
  */
398
537
  function buildExpectedShowConfigPath(cwd: string): string {
399
- return buildExpectedNotifyBasePath(path.dirname(path.dirname(getProjectConfigPath(cwd)))) === buildExpectedNotifyBasePath(cwd)
400
- ? `${buildExpectedNotifyBasePath(cwd)}/.pi-usereq/config.json`
538
+ return buildExpectedNotifyBasePath(path.dirname(getProjectConfigPath(cwd))) === buildExpectedNotifyBasePath(cwd)
539
+ ? `${buildExpectedNotifyBasePath(cwd)}/.pi-usereq.json`
401
540
  : getProjectConfigPath(cwd).split(path.sep).join("/");
402
541
  }
403
542
 
@@ -430,7 +569,13 @@ function createFakeCtx(cwd: string, plan: FakeCtxPlan = { selects: [] }, options
430
569
  const selects = [...plan.selects];
431
570
  const inputs = [...(plan.inputs ?? [])];
432
571
  const getContextUsage = options.getContextUsage ?? (() => undefined);
433
- const sessionManager = options.sessionManager ?? createFakeSessionManager();
572
+ const baseSessionManager = options.sessionManager ?? createFakeSessionManager(cwd);
573
+ let currentSessionCwd = typeof baseSessionManager.getCwd === "function"
574
+ ? baseSessionManager.getCwd()
575
+ : cwd;
576
+ let currentSessionFile = typeof baseSessionManager.getSessionFile === "function"
577
+ ? baseSessionManager.getSessionFile()
578
+ : createFakeSessionFile(currentSessionCwd);
434
579
  const theme = options.theme ?? {
435
580
  fg: (color: string, text: string) => formatFakeThemeForeground(color, text),
436
581
  bgFromFg: (color: string, text: string) => formatFakeThemeBackgroundFromForeground(color, text),
@@ -445,13 +590,43 @@ function createFakeCtx(cwd: string, plan: FakeCtxPlan = { selects: [] }, options
445
590
  waitForIdleCalls: 0,
446
591
  newSessions: [] as Array<{ messages: string[] }>,
447
592
  };
448
- return {
593
+ const sessionManager: FakeSessionManager = {
594
+ appendCustomEntry(customType: string, data?: unknown): string {
595
+ return baseSessionManager.appendCustomEntry(customType, data);
596
+ },
597
+ appendMessage(message: unknown): string {
598
+ return baseSessionManager.appendMessage(message);
599
+ },
600
+ getBranch(): FakeSessionEntry[] {
601
+ return baseSessionManager.getBranch();
602
+ },
603
+ getEntries(): FakeSessionEntry[] {
604
+ return baseSessionManager.getEntries();
605
+ },
606
+ getCwd(): string {
607
+ return currentSessionCwd;
608
+ },
609
+ getSessionDir(): string | undefined {
610
+ return typeof currentSessionFile === "string" ? path.dirname(currentSessionFile) : undefined;
611
+ },
612
+ getSessionFile(): string | undefined {
613
+ return currentSessionFile;
614
+ },
615
+ };
616
+ const fakeCtx: any = {
449
617
  cwd,
450
618
  __state: state,
451
619
  sessionManager,
452
620
  async waitForIdle() {
453
621
  state.waitForIdleCalls += 1;
454
622
  },
623
+ async switchSession(sessionPath: string) {
624
+ currentSessionFile = sessionPath;
625
+ currentSessionCwd = readFakeSessionFileCwd(sessionPath, currentSessionCwd);
626
+ fakeCtx.cwd = currentSessionCwd;
627
+ process.chdir(currentSessionCwd);
628
+ return { cancelled: false };
629
+ },
455
630
  async newSession(newSessionOptions?: {
456
631
  parentSession?: string;
457
632
  setup?: (sessionManager: FakeSessionManager) => Promise<void>;
@@ -473,6 +648,15 @@ function createFakeCtx(cwd: string, plan: FakeCtxPlan = { selects: [] }, options
473
648
  getEntries(): FakeSessionEntry[] {
474
649
  return sessionManager.getEntries();
475
650
  },
651
+ getCwd(): string {
652
+ return sessionManager.getCwd();
653
+ },
654
+ getSessionDir(): string | undefined {
655
+ return sessionManager.getSessionDir();
656
+ },
657
+ getSessionFile(): string | undefined {
658
+ return sessionManager.getSessionFile();
659
+ },
476
660
  };
477
661
  if (newSessionOptions?.setup) {
478
662
  await newSessionOptions.setup(setupSessionManager);
@@ -529,6 +713,8 @@ function createFakeCtx(cwd: string, plan: FakeCtxPlan = { selects: [] }, options
529
713
  });
530
714
  if (response === undefined) {
531
715
  bridge.cancel();
716
+ } else if (response === "Save and close" && !bridge.choices.some((choice) => choice.label === response)) {
717
+ bridge.cancel();
532
718
  } else {
533
719
  assert.ok(bridge.selectByLabel(response), `unsupported custom selection ${response}`);
534
720
  }
@@ -549,6 +735,7 @@ function createFakeCtx(cwd: string, plan: FakeCtxPlan = { selects: [] }, options
549
735
  },
550
736
  },
551
737
  };
738
+ return fakeCtx;
552
739
  }
553
740
 
554
741
  /**
@@ -604,25 +791,19 @@ test("extension registers prompt and config commands while exposing tool capabil
604
791
  ]) {
605
792
  assert.ok(commandNames.includes(name), `missing command ${name}`);
606
793
  }
794
+ assert.equal(pi.commands.get("req-analyze")?.description, "Produce an analysis report");
607
795
 
608
796
  for (const name of [
609
- "git-path",
610
- "get-base-path",
611
797
  "files-tokens",
612
798
  "files-references",
613
799
  "files-compress",
614
- "files-find",
800
+ "files-search",
615
801
  "references",
616
802
  "compress",
617
- "find",
803
+ "search",
618
804
  "tokens",
619
805
  "files-static-check",
620
806
  "static-check",
621
- "git-check",
622
- "docs-check",
623
- "git-wt-name",
624
- "git-wt-create",
625
- "git-wt-delete",
626
807
  "pi-usereq-show-config",
627
808
  "test-static-check",
628
809
  ]) {
@@ -631,23 +812,16 @@ test("extension registers prompt and config commands while exposing tool capabil
631
812
 
632
813
  const toolNames = pi.tools.map((tool: RegisteredTool) => tool.name).sort();
633
814
  for (const name of [
634
- "git-path",
635
- "get-base-path",
636
815
  "files-tokens",
637
816
  "files-references",
638
817
  "files-compress",
639
- "files-find",
818
+ "files-search",
640
819
  "references",
641
820
  "compress",
642
- "find",
821
+ "search",
643
822
  "tokens",
644
823
  "files-static-check",
645
824
  "static-check",
646
- "git-check",
647
- "docs-check",
648
- "git-wt-name",
649
- "git-wt-create",
650
- "git-wt-delete",
651
825
  ]) {
652
826
  assert.ok(toolNames.includes(name), `missing tool ${name}`);
653
827
  }
@@ -662,13 +836,13 @@ test("token tools register agent-oriented descriptions and schema details", () =
662
836
  assert.ok(filesTokens, "missing files-tokens tool");
663
837
  assert.ok(tokens, "missing tokens tool");
664
838
 
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/);
839
+ assert.match(filesTokens.description ?? "", /monolithic token pack summary/);
840
+ assert.ok(filesTokens.promptGuidelines?.some((line) => line.includes("monolithic pack-summary text")));
841
+ assert.match(String((filesTokens.parameters as { description?: string } | undefined)?.description ?? ""), /monolithic pack-summary text/);
668
842
 
669
843
  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/);
844
+ assert.ok(tokens.promptGuidelines?.some((line) => line.includes("monolithic pack-summary text")));
845
+ assert.match(String((tokens.parameters as { description?: string } | undefined)?.description ?? ""), /monolithic pack-summary text/);
672
846
  });
673
847
 
674
848
  test("reference tools register agent-oriented descriptions and schema details", () => {
@@ -680,36 +854,36 @@ test("reference tools register agent-oriented descriptions and schema details",
680
854
  assert.ok(filesReferences, "missing files-references tool");
681
855
  assert.ok(references, "missing references tool");
682
856
 
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/);
857
+ assert.match(filesReferences.description ?? "", /monolithic references markdown report/);
858
+ assert.ok(filesReferences.promptGuidelines?.some((line) => line.includes("monolithic markdown")));
859
+ assert.ok(filesReferences.promptGuidelines?.some((line) => line.includes("Formatting contract:")));
860
+ assert.match(String((filesReferences.parameters as { description?: string } | undefined)?.description ?? ""), /monolithic markdown/);
687
861
 
688
862
  assert.match(references.description ?? "", /configured project source directories/);
689
- assert.ok(references.promptGuidelines?.some((line) => line.includes("source_directory_paths")));
863
+ assert.ok(references.promptGuidelines?.some((line) => line.includes("file-structure markdown block")));
690
864
  assert.ok(references.promptGuidelines?.some((line) => line.includes("Configuration contract:")));
691
- assert.match(String((references.parameters as { description?: string } | undefined)?.description ?? ""), /directory tree/);
865
+ assert.match(String((references.parameters as { description?: string } | undefined)?.description ?? ""), /monolithic markdown/);
692
866
  });
693
867
 
694
- test("find tools register agent-oriented descriptions and schema details", () => {
868
+ test("search tools register agent-oriented descriptions and schema details", () => {
695
869
  const pi = createFakePi();
696
870
  piUsereqExtension(pi);
697
871
 
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/);
872
+ const filesSearch = pi.tools.find((tool: RegisteredTool) => tool.name === "files-search");
873
+ const search = pi.tools.find((tool: RegisteredTool) => tool.name === "search");
874
+ assert.ok(filesSearch, "missing files-search tool");
875
+ assert.ok(search, "missing search tool");
876
+
877
+ assert.match(filesSearch.description ?? "", /monolithic construct-search markdown report/);
878
+ assert.ok(filesSearch.promptGuidelines?.some((line) => line.includes("Regex rule:")));
879
+ assert.ok(filesSearch.promptGuidelines?.some((line) => line.includes("Supported tags [Typescript]:")));
880
+ assert.ok(filesSearch.promptGuidelines?.some((line) => line.includes("Supported tags [Python]:")));
881
+ assert.match(String((filesSearch.parameters as { description?: string } | undefined)?.description ?? ""), /monolithic markdown/);
882
+
883
+ assert.match(search.description ?? "", /configured project source directories/);
884
+ assert.ok(search.promptGuidelines?.some((line) => line.includes("monolithic markdown")));
885
+ assert.ok(search.promptGuidelines?.some((line) => line.includes("Tag rule:")));
886
+ assert.match(String((search.parameters as { description?: string } | undefined)?.description ?? ""), /monolithic markdown/);
713
887
  });
714
888
 
715
889
  test("compression tools register agent-oriented descriptions and schema details", () => {
@@ -721,18 +895,69 @@ test("compression tools register agent-oriented descriptions and schema details"
721
895
  assert.ok(filesCompress, "missing files-compress tool");
722
896
  assert.ok(compress, "missing compress tool");
723
897
 
724
- assert.match(filesCompress.description ?? "", /summary, repository, files, and execution sections/);
898
+ assert.match(filesCompress.description ?? "", /monolithic compression markdown report/);
725
899
  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/);
900
+ assert.ok(filesCompress.promptGuidelines?.some((line) => line.includes("Formatting contract:")));
901
+ assert.match(String((filesCompress.parameters as { description?: string } | undefined)?.description ?? ""), /monolithic markdown/);
728
902
 
729
903
  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/);
904
+ assert.ok(compress.promptGuidelines?.some((line) => line.includes("monolithic markdown")));
905
+ assert.ok(compress.promptGuidelines?.some((line) => line.includes("Formatting contract:")));
906
+ assert.match(String((compress.parameters as { description?: string } | undefined)?.description ?? ""), /monolithic markdown/);
907
+ });
908
+
909
+ test("agent tools define custom renderResult with compact and expanded structured views", async () => {
910
+ const { projectBase } = initFixtureRepo({ fixtures: [] });
911
+ try {
912
+ const pi = createFakePi();
913
+ piUsereqExtension(pi);
914
+ const toolNames = [
915
+ "files-tokens",
916
+ "files-references",
917
+ "files-compress",
918
+ "files-search",
919
+ "references",
920
+ "compress",
921
+ "search",
922
+ "tokens",
923
+ "files-static-check",
924
+ "static-check",
925
+ ];
926
+ for (const toolName of toolNames) {
927
+ const tool = pi.tools.find((candidate: RegisteredTool) => candidate.name === toolName);
928
+ assert.ok(tool, `missing tool ${toolName}`);
929
+ assert.equal(typeof tool?.renderResult, "function", `missing renderResult for ${toolName}`);
930
+ }
931
+
932
+ fs.writeFileSync(path.join(projectBase, "src", "render_result_target.ts"), "export function alpha(): number {\n return 1;\n}\n", "utf8");
933
+ const tool = pi.tools.find((candidate: RegisteredTool) => candidate.name === "files-search");
934
+ assert.ok(tool?.renderResult, "missing renderResult for files-search");
935
+ const params = {
936
+ tag: "FUNCTION",
937
+ pattern: "^alpha$",
938
+ files: ["src/render_result_target.ts"],
939
+ enableLineNumbers: true,
940
+ };
941
+ const result = await executeRegisteredTool(pi, "files-search", projectBase, params);
942
+ const theme: FakeThemeAdapter = {
943
+ fg: (color: string, text: string) => formatFakeThemeForeground(color, text),
944
+ bgFromFg: (color: string, text: string) => formatFakeThemeBackgroundFromForeground(color, text),
945
+ bold: (text: string) => `<bold>${text}</bold>`,
946
+ };
947
+ const compact = tool.renderResult(result, { expanded: false, isPartial: false }, theme, { args: params }).render(120).join("\n");
948
+ const expanded = tool.renderResult(result, { expanded: true, isPartial: false }, theme, { args: params }).render(120).join("\n");
949
+ assert.match(compact, /files-search/);
950
+ assert.match(compact, /tag="FUNCTION"/);
951
+ assert.match(compact, /pattern="\^alpha\$"/);
952
+ assert.match(compact, /files=\["src\/render_result_target\.ts"\]/);
953
+ assert.doesNotMatch(compact, /### FUNCTION/);
954
+ assert.match(expanded, /### FUNCTION: `alpha`/);
955
+ } finally {
956
+ fs.rmSync(projectBase, { recursive: true, force: true });
957
+ }
733
958
  });
734
959
 
735
- test("files-tokens returns token-optimized structured source facts", async () => {
960
+ test("files-tokens returns monolithic token summary text with execution diagnostics", async () => {
736
961
  const { projectBase } = initFixtureRepo({
737
962
  fixtures: [],
738
963
  docs: {
@@ -748,45 +973,20 @@ test("files-tokens returns token-optimized structured source facts", async () =>
748
973
  files: [`${DEFAULT_DOCS_DIR}/REQUIREMENTS.md`, `${DEFAULT_DOCS_DIR}/MISSING.md`],
749
974
  }) as {
750
975
  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
- };
976
+ details?: { execution: { code: number; stderr_lines?: string[] } };
770
977
  };
771
978
 
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`));
979
+ const content = result.content?.[0]?.text ?? "";
980
+ assert.match(content, /Pack Summary/);
981
+ assert.match(content, /REQUIREMENTS\.md/);
982
+ assert.equal(result.details?.execution.code, 0);
983
+ assert.ok(result.details?.execution.stderr_lines?.some((line) => /MISSING\.md/.test(line)));
784
984
  } finally {
785
985
  fs.rmSync(projectBase, { recursive: true, force: true });
786
986
  }
787
987
  });
788
988
 
789
- test("files-tokens defers js-tiktoken loading until execution and returns structured dependency failures", async () => {
989
+ test("files-tokens defers js-tiktoken loading until execution and returns monolithic dependency failures", async () => {
790
990
  const { projectBase } = initFixtureRepo({ fixtures: [] });
791
991
  const missingModuleError = new Error("Cannot find module 'js-tiktoken'");
792
992
  setJsTiktokenModuleLoaderForTests(() => {
@@ -798,35 +998,20 @@ test("files-tokens defers js-tiktoken loading until execution and returns struct
798
998
  const result = await executeRegisteredTool(pi, "files-tokens", projectBase, {
799
999
  files: [`${DEFAULT_DOCS_DIR}/REQUIREMENTS.md`],
800
1000
  }) 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
- };
1001
+ content?: Array<{ type: string; text?: string }>;
1002
+ details?: { execution: { code: number; stderr_lines?: string[] } };
813
1003
  };
814
1004
 
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/);
1005
+ assert.equal(result.details?.execution.code, 1);
1006
+ assert.match(result.content?.[0]?.text ?? "", /js-tiktoken/);
1007
+ assert.ok(result.details?.execution.stderr_lines?.some((line) => /npm ci/.test(line)));
823
1008
  } finally {
824
1009
  setJsTiktokenModuleLoaderForTests(undefined);
825
1010
  fs.rmSync(projectBase, { recursive: true, force: true });
826
1011
  }
827
1012
  });
828
1013
 
829
- test("files-references returns structured repository, symbol, and Doxygen facts", async () => {
1014
+ test("files-references returns monolithic references markdown", async () => {
830
1015
  const { projectBase } = initFixtureRepo({ fixtures: [] });
831
1016
  const samplePath = path.join(projectBase, "src", "sample.ts");
832
1017
  fs.writeFileSync(samplePath, `/**
@@ -851,66 +1036,22 @@ export function buildSample(input: string): string {
851
1036
  files: ["src/sample.ts", "src/missing.ts"],
852
1037
  }) as {
853
1038
  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
- };
1039
+ details?: { execution: { code: number; stderr_lines?: string[] } };
885
1040
  };
886
1041
 
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/);
1042
+ const content = result.content?.[0]?.text ?? "";
1043
+ assert.match(content, /# sample\.ts \| TypeScript/);
1044
+ assert.match(content, /## Definitions/);
1045
+ assert.match(content, /buildSample/);
1046
+ assert.match(content, /Caller-supplied value\./);
1047
+ assert.equal(result.details?.execution.code, 0);
1048
+ assert.deepEqual(result.details?.execution.stderr_lines, undefined);
908
1049
  } finally {
909
1050
  fs.rmSync(projectBase, { recursive: true, force: true });
910
1051
  }
911
1052
  });
912
1053
 
913
- test("references returns a structured repository tree for configured source directories", async () => {
1054
+ test("references returns monolithic project references markdown", async () => {
914
1055
  const { projectBase } = initFixtureRepo({ fixtures: [] });
915
1056
  fs.writeFileSync(path.join(projectBase, "src", "alpha.ts"), "export const ALPHA = 1;\n", "utf8");
916
1057
  fs.mkdirSync(path.join(projectBase, "src", "nested"), { recursive: true });
@@ -920,49 +1061,23 @@ test("references returns a structured repository tree for configured source dire
920
1061
  piUsereqExtension(pi);
921
1062
  const result = await executeRegisteredTool(pi, "references", projectBase, {}) as {
922
1063
  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
- };
1064
+ details?: { execution: { code: number; stderr_lines?: string[] } };
942
1065
  };
943
1066
 
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]);
1067
+ const content = result.content?.[0]?.text ?? "";
1068
+ assert.match(content, /# Files Structure/);
1069
+ assert.match(content, /src\/alpha\.ts/);
1070
+ assert.match(content, /src\/nested\/beta\.ts/);
1071
+ assert.match(content, /# alpha\.ts \| TypeScript/);
1072
+ assert.match(content, /# beta\.ts \| TypeScript/);
1073
+ assert.equal(result.details?.execution.code, 0);
1074
+ assert.deepEqual(result.details?.execution.stderr_lines, undefined);
960
1075
  } finally {
961
1076
  fs.rmSync(projectBase, { recursive: true, force: true });
962
1077
  }
963
1078
  });
964
1079
 
965
- test("files-compress returns structured line, symbol, and Doxygen facts", async () => {
1080
+ test("files-compress returns monolithic compression markdown", async () => {
966
1081
  const { projectBase } = initFixtureRepo({ fixtures: [] });
967
1082
  const samplePath = path.join(projectBase, "src", "sample.ts");
968
1083
  fs.writeFileSync(samplePath, `/**
@@ -988,68 +1103,21 @@ export function buildSample(input: string): string {
988
1103
  enableLineNumbers: true,
989
1104
  }) as {
990
1105
  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
- };
1106
+ details?: { execution: { code: number; stderr_lines?: string[] } };
1022
1107
  };
1023
1108
 
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/);
1109
+ const content = result.content?.[0]?.text ?? "";
1110
+ assert.match(content, /@@@ src\/sample\.ts \| typescript/);
1111
+ assert.match(content, /> Lines: 11-14/);
1112
+ assert.match(content, /^11: export function buildSample/m);
1113
+ assert.equal(result.details?.execution.code, 0);
1114
+ assert.deepEqual(result.details?.execution.stderr_lines, undefined);
1047
1115
  } finally {
1048
1116
  fs.rmSync(projectBase, { recursive: true, force: true });
1049
1117
  }
1050
1118
  });
1051
1119
 
1052
- test("compress returns a structured repository-scoped compression payload", async () => {
1120
+ test("compress returns monolithic repository-scoped compression markdown", async () => {
1053
1121
  const { projectBase } = initFixtureRepo({ fixtures: [] });
1054
1122
  fs.writeFileSync(
1055
1123
  path.join(projectBase, "src", "alpha.ts"),
@@ -1069,45 +1137,21 @@ test("compress returns a structured repository-scoped compression payload", asyn
1069
1137
  enableLineNumbers: false,
1070
1138
  }) as {
1071
1139
  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
- };
1140
+ details?: { execution: { code: number; stderr_lines?: string[] } };
1092
1141
  };
1093
1142
 
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, "");
1143
+ const content = result.content?.[0]?.text ?? "";
1144
+ assert.match(content, /@@@ src\/alpha\.ts \| typescript/);
1145
+ assert.match(content, /@@@ src\/nested\/beta\.ts \| typescript/);
1146
+ assert.match(content, /^export function buildBeta/m);
1147
+ assert.equal(result.details?.execution.code, 0);
1148
+ assert.deepEqual(result.details?.execution.stderr_lines, undefined);
1105
1149
  } finally {
1106
1150
  fs.rmSync(projectBase, { recursive: true, force: true });
1107
1151
  }
1108
1152
  });
1109
1153
 
1110
- test("files-find returns structured match, code-line, and Doxygen facts", async () => {
1154
+ test("files-search returns monolithic search markdown", async () => {
1111
1155
  const { projectBase } = initFixtureRepo({ fixtures: [] });
1112
1156
  const samplePath = path.join(projectBase, "src", "sample.ts");
1113
1157
  fs.writeFileSync(samplePath, `/**
@@ -1128,86 +1172,29 @@ export function buildSample(input: string): string {
1128
1172
  try {
1129
1173
  const pi = createFakePi();
1130
1174
  piUsereqExtension(pi);
1131
- const result = await executeRegisteredTool(pi, "files-find", projectBase, {
1175
+ const result = await executeRegisteredTool(pi, "files-search", projectBase, {
1132
1176
  tag: "FUNCTION",
1133
1177
  pattern: "^buildSample$",
1134
1178
  files: ["src/sample.ts", "src/missing.ts"],
1135
1179
  enableLineNumbers: true,
1136
1180
  }) as {
1137
1181
  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
- };
1182
+ details?: { execution: { code: number; stderr_lines?: string[] } };
1175
1183
  };
1176
1184
 
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/);
1185
+ const content = result.content?.[0]?.text ?? "";
1186
+ assert.match(content, /### FUNCTION: `buildSample`/);
1187
+ assert.match(content, /> Signature: `export function buildSample\(input: string\): string`/);
1188
+ assert.match(content, /> Lines: 12-14/);
1189
+ assert.match(content, /^12: export function buildSample/m);
1190
+ assert.equal(result.details?.execution.code, 0);
1191
+ assert.deepEqual(result.details?.execution.stderr_lines, undefined);
1205
1192
  } finally {
1206
1193
  fs.rmSync(projectBase, { recursive: true, force: true });
1207
1194
  }
1208
1195
  });
1209
1196
 
1210
- test("find returns a structured repository-scoped construct-search payload", async () => {
1197
+ test("search returns monolithic repository-scoped construct-search markdown", async () => {
1211
1198
  const { projectBase } = initFixtureRepo({ fixtures: [] });
1212
1199
  fs.writeFileSync(
1213
1200
  path.join(projectBase, "src", "alpha.ts"),
@@ -1230,63 +1217,120 @@ export function buildAlpha(): number {
1230
1217
  try {
1231
1218
  const pi = createFakePi();
1232
1219
  piUsereqExtension(pi);
1233
- const result = await executeRegisteredTool(pi, "find", projectBase, {
1220
+ const result = await executeRegisteredTool(pi, "search", projectBase, {
1234
1221
  tag: "FUNCTION",
1235
1222
  pattern: "^build",
1236
1223
  enableLineNumbers: false,
1237
1224
  }) as {
1238
1225
  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
- };
1226
+ details?: { execution: { code: number; stderr_lines?: string[] } };
1227
+ };
1228
+
1229
+ const content = result.content?.[0]?.text ?? "";
1230
+ assert.match(content, /### FUNCTION: `buildAlpha`/);
1231
+ assert.match(content, /export function buildAlpha/);
1232
+ assert.doesNotMatch(content, /helperBeta/);
1233
+ assert.equal(result.details?.execution.code, 0);
1234
+ assert.deepEqual(result.details?.execution.stderr_lines, undefined);
1235
+ } finally {
1236
+ fs.rmSync(projectBase, { recursive: true, force: true });
1237
+ }
1238
+ });
1239
+
1240
+ test("source-extraction agent tools preserve leading tabs in emitted content", async () => {
1241
+ const { projectBase } = initFixtureRepo({ fixtures: [] });
1242
+ const samplePath = path.join(projectBase, "src", "sample.go");
1243
+ fs.writeFileSync(samplePath, `package main
1244
+
1245
+ import (
1246
+ "bytes"
1247
+ "context"
1248
+ "fmt"
1249
+ )
1250
+
1251
+ type Runner interface {
1252
+ Run(ctx context.Context) error
1253
+ }
1254
+
1255
+ type Server struct{}
1256
+
1257
+ func (s *Server) Start(ctx context.Context) error {
1258
+ var buffer bytes.Buffer
1259
+ fmt.Println("start", buffer.Len())
1260
+ return nil
1261
+ }
1262
+ `, "utf8");
1263
+ try {
1264
+ const pi = createFakePi();
1265
+ piUsereqExtension(pi);
1266
+
1267
+ const filesReferencesResult = await executeRegisteredTool(pi, "files-references", projectBase, {
1268
+ files: ["src/sample.go"],
1269
+ }) as {
1270
+ content?: Array<{ type: string; text?: string }>;
1271
+ details?: { execution: { code: number } };
1272
+ };
1273
+ assert.match(filesReferencesResult.content?.[0]?.text ?? "", /\tvar buffer bytes\.Buffer/);
1274
+ assert.equal(filesReferencesResult.details?.execution.code, 0);
1275
+
1276
+ const referencesResult = await executeRegisteredTool(pi, "references", projectBase, {}) as {
1277
+ content?: Array<{ type: string; text?: string }>;
1278
+ details?: { execution: { code: number } };
1279
+ };
1280
+ assert.match(referencesResult.content?.[0]?.text ?? "", /\tvar buffer bytes\.Buffer/);
1281
+ assert.equal(referencesResult.details?.execution.code, 0);
1282
+
1283
+ const filesCompressResult = await executeRegisteredTool(pi, "files-compress", projectBase, {
1284
+ files: ["src/sample.go"],
1285
+ enableLineNumbers: true,
1286
+ }) as {
1287
+ content?: Array<{ type: string; text?: string }>;
1288
+ details?: { execution: { code: number } };
1289
+ };
1290
+ assert.match(filesCompressResult.content?.[0]?.text ?? "", /\d+: \t"context"/);
1291
+ assert.equal(filesCompressResult.details?.execution.code, 0);
1292
+
1293
+ const compressResult = await executeRegisteredTool(pi, "compress", projectBase, {
1294
+ enableLineNumbers: true,
1295
+ }) as {
1296
+ content?: Array<{ type: string; text?: string }>;
1297
+ details?: { execution: { code: number } };
1298
+ };
1299
+ assert.match(compressResult.content?.[0]?.text ?? "", /\d+: \t"context"/);
1300
+ assert.equal(compressResult.details?.execution.code, 0);
1301
+
1302
+ const filesSearchResult = await executeRegisteredTool(pi, "files-search", projectBase, {
1303
+ tag: "METHOD|FUNCTION",
1304
+ pattern: "^Start$",
1305
+ files: ["src/sample.go"],
1306
+ enableLineNumbers: true,
1307
+ }) as {
1308
+ content?: Array<{ type: string; text?: string }>;
1309
+ details?: { execution: { code: number } };
1264
1310
  };
1311
+ assert.match(filesSearchResult.content?.[0]?.text ?? "", /\d+: \tfmt\.Println\("start", buffer\.Len\(\)\)/);
1312
+ assert.equal(filesSearchResult.details?.execution.code, 0);
1265
1313
 
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/);
1314
+ const searchResult = await executeRegisteredTool(pi, "search", projectBase, {
1315
+ tag: "METHOD|FUNCTION",
1316
+ pattern: "^Start$",
1317
+ enableLineNumbers: true,
1318
+ }) as {
1319
+ content?: Array<{ type: string; text?: string }>;
1320
+ details?: { execution: { code: number } };
1321
+ };
1322
+ assert.match(searchResult.content?.[0]?.text ?? "", /\d+: \tfmt\.Println\("start", buffer\.Len\(\)\)/);
1323
+ assert.equal(searchResult.details?.execution.code, 0);
1280
1324
  } finally {
1281
1325
  fs.rmSync(projectBase, { recursive: true, force: true });
1282
1326
  }
1283
1327
  });
1284
1328
 
1285
- test("path, static-check, git, docs, and worktree tools return structured JSON payloads", async () => {
1329
+ test("static-check tools return monolithic text payloads with minimal execution details", async () => {
1286
1330
  const { projectBase } = initFixtureRepo({
1287
1331
  fixtures: [],
1288
1332
  staticCheck: {
1289
- TypeScript: [{ module: "Dummy" }],
1333
+ TypeScript: createStaticCheckLanguageConfig([{ module: "Dummy" }], "enable"),
1290
1334
  },
1291
1335
  });
1292
1336
  fs.writeFileSync(path.join(projectBase, "src", "check.ts"), "export const VALUE = 1;\n", "utf8");
@@ -1298,97 +1342,46 @@ test("path, static-check, git, docs, and worktree tools return structured JSON p
1298
1342
  const pi = createFakePi();
1299
1343
  piUsereqExtension(pi);
1300
1344
 
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
1345
  const filesStaticCheckResult = await executeRegisteredTool(pi, "files-static-check", projectBase, {
1343
1346
  files: ["src/check.ts", "src/missing.ts"],
1344
1347
  }) 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
- };
1348
+ content?: Array<{ type: string; text?: string }>;
1349
+ details?: { execution: { code: number; stderr_lines?: string[] } };
1350
1350
  };
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");
1351
+ assert.match(filesStaticCheckResult.content?.[0]?.text ?? "", /src\/missing\.ts/);
1358
1352
  assert.equal(filesStaticCheckResult.details?.execution.code, 0);
1353
+ assert.ok(filesStaticCheckResult.details?.execution.stderr_lines?.some((line) => /src\/missing\.ts/.test(line)));
1359
1354
 
1360
1355
  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
- };
1356
+ content?: Array<{ type: string; text?: string }>;
1357
+ details?: { execution: { code: number; stderr_lines?: string[] } };
1366
1358
  };
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");
1359
+ assert.equal(staticCheckResult.content?.[0]?.text ?? "", "");
1370
1360
  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 } };
1361
+ assert.deepEqual(staticCheckResult.details?.execution.stderr_lines, undefined);
1362
+
1363
+ fs.writeFileSync(
1364
+ getProjectConfigPath(projectBase),
1365
+ `${JSON.stringify({
1366
+ ...getDefaultConfig(projectBase),
1367
+ "docs-dir": DEFAULT_DOCS_DIR,
1368
+ "tests-dir": "tests",
1369
+ "src-dir": ["src"],
1370
+ "static-check": {
1371
+ TypeScript: createStaticCheckLanguageConfig([{ module: "Dummy" }], "disable"),
1372
+ },
1373
+ }, null, 2)}\n`,
1374
+ "utf8",
1375
+ );
1376
+ const disabledFilesStaticCheckResult = await executeRegisteredTool(pi, "files-static-check", projectBase, {
1377
+ files: ["src/check.ts"],
1378
+ }) as {
1379
+ content?: Array<{ type: string; text?: string }>;
1380
+ details?: { execution: { code: number; stderr_lines?: string[] } };
1388
1381
  };
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);
1382
+ assert.equal(disabledFilesStaticCheckResult.content?.[0]?.text ?? "", "");
1383
+ assert.equal(disabledFilesStaticCheckResult.details?.execution.code, 0);
1384
+ assert.deepEqual(disabledFilesStaticCheckResult.details?.execution.stderr_lines, undefined);
1392
1385
  } finally {
1393
1386
  fs.rmSync(projectBase, { recursive: true, force: true });
1394
1387
  }
@@ -1407,6 +1400,57 @@ test("configuration menu saves updated docs-dir in project config", async () =>
1407
1400
  assert.equal(config["docs-dir"], "docs/custom");
1408
1401
  });
1409
1402
 
1403
+ test("configuration menu persists trailing-slash-free relative directory values", async () => {
1404
+ const cwd = createTempDir("pi-usereq-menu-path-contracts-");
1405
+ fs.mkdirSync(path.dirname(getProjectConfigPath(cwd)), { recursive: true });
1406
+ const pi = createFakePi();
1407
+ piUsereqExtension(pi);
1408
+ const command = pi.commands.get("pi-usereq");
1409
+ assert.ok(command);
1410
+ const ctx = createFakeCtx(cwd, {
1411
+ selects: [
1412
+ "docs-dir",
1413
+ "tests-dir",
1414
+ "src-dir",
1415
+ "add-src-dir-entry",
1416
+ "Save and close",
1417
+ "Save and close",
1418
+ ],
1419
+ inputs: ["docs/custom/", "tests/custom/", "src/custom/"],
1420
+ });
1421
+
1422
+ await command!.handler("", ctx);
1423
+
1424
+ const config = JSON.parse(fs.readFileSync(getProjectConfigPath(cwd), "utf8"));
1425
+ assert.equal(config["docs-dir"], "docs/custom");
1426
+ assert.equal(config["tests-dir"], "tests/custom");
1427
+ assert.deepEqual(config["src-dir"], ["src", "src/custom"]);
1428
+ });
1429
+
1430
+ test("configuration menu forces worktree rows off and dim when auto git commit is disabled", async () => {
1431
+ const cwd = createTempDir("pi-usereq-menu-worktree-");
1432
+ fs.mkdirSync(path.dirname(getProjectConfigPath(cwd)), { recursive: true });
1433
+ const pi = createFakePi();
1434
+ piUsereqExtension(pi);
1435
+ const command = pi.commands.get("pi-usereq");
1436
+ assert.ok(command);
1437
+ const ctx = createFakeCtx(cwd, {
1438
+ selects: ["Auto git commit", "Git worktree", "Worktree prefix", "Save and close"],
1439
+ inputs: ["custom-prefix-"],
1440
+ });
1441
+
1442
+ await command!.handler("", ctx);
1443
+
1444
+ const config = JSON.parse(fs.readFileSync(getProjectConfigPath(cwd), "utf8"));
1445
+ const defaultConfig = getDefaultConfig(cwd);
1446
+ const renderedLockedMenu = (ctx.__state.customRenderLines[1] ?? []).join("\n");
1447
+ assert.equal(config.AUTO_GIT_COMMIT, "disable");
1448
+ assert.equal(config.GIT_WORKTREE_ENABLED, "disable");
1449
+ assert.equal(config.GIT_WORKTREE_PREFIX, defaultConfig.GIT_WORKTREE_PREFIX);
1450
+ assert.match(renderedLockedMenu, /<dim>Git worktree/);
1451
+ assert.match(renderedLockedMenu, /<dim>Worktree prefix/);
1452
+ });
1453
+
1410
1454
  test("configuration menu omits prompt-delivery controls and persisted config omits removed runtime fields", async () => {
1411
1455
  const cwd = createTempDir("pi-usereq-menu-current-session-");
1412
1456
  fs.mkdirSync(path.dirname(getProjectConfigPath(cwd)), { recursive: true });
@@ -1419,11 +1463,8 @@ test("configuration menu omits prompt-delivery controls and persisted config omi
1419
1463
  await command!.handler("", ctx);
1420
1464
 
1421
1465
  const menuItems = ctx.__state.selectCalls[0]?.items ?? [];
1422
- const config = JSON.parse(fs.readFileSync(getProjectConfigPath(cwd), "utf8"));
1423
1466
  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);
1467
+ assert.equal(fs.existsSync(getProjectConfigPath(cwd)), false);
1427
1468
  });
1428
1469
 
1429
1470
  test("configuration menus expose show-config ordering and omit overview or notification-reference rows", async () => {
@@ -1435,26 +1476,33 @@ test("configuration menus expose show-config ordering and omit overview or notif
1435
1476
  piUsereqExtension(pi);
1436
1477
  const command = pi.commands.get("pi-usereq");
1437
1478
  assert.ok(command);
1438
- const ctx = createFakeCtx(cwd, { selects: ["show-config", "notifications", "Save and close", "Save and close"] });
1479
+ const ctx = createFakeCtx(cwd, { selects: ["notifications", "Save and close", "Save and close"] });
1439
1480
 
1440
1481
  await command!.handler("", ctx);
1441
1482
 
1442
1483
  const renderedMenu = (ctx.__state.customRenderLines[0] ?? []).join("\n");
1484
+ const renderedNotificationsMenu = (ctx.__state.customRenderLines[1] ?? []).join("\n");
1443
1485
  assert.deepEqual(ctx.__state.selectCalls[0]?.items ?? [], [
1444
1486
  "Document directory",
1445
1487
  "Source-code directories",
1446
1488
  "Unit tests directory",
1489
+ "Auto git commit",
1490
+ "Git worktree",
1491
+ "Worktree prefix",
1447
1492
  "Language static code checkers",
1448
1493
  "Enable tools",
1449
1494
  "Notifications",
1495
+ "Debug",
1450
1496
  "Show configuration",
1451
1497
  "Reset defaults",
1452
- "Save and close",
1453
1498
  ]);
1454
1499
  assert.ok(renderedMenu.includes(buildExpectedShowConfigPath(cwd)));
1455
1500
  assert.ok(renderedMenu.includes("notification:off • sound:none • pushover:off"));
1501
+ assert.ok(renderedMenu.includes("disable"));
1456
1502
  assert.doesNotMatch(renderedMenu, /beep:/);
1457
- assert.deepEqual(ctx.__state.selectCalls[2]?.items ?? [], [
1503
+ assert.match(renderedNotificationsMenu, /<dim>Enable pushover/);
1504
+ assert.match(renderedNotificationsMenu, /<dim>off<\/dim>/);
1505
+ assert.deepEqual(ctx.__state.selectCalls[1]?.items ?? [], [
1458
1506
  "Enable notification",
1459
1507
  "Notification events",
1460
1508
  "Notify command",
@@ -1472,51 +1520,34 @@ test("configuration menus expose show-config ordering and omit overview or notif
1472
1520
  "Pushover User Key/Delivery Group Key",
1473
1521
  "Pushover Token/API Token Key",
1474
1522
  "Reset defaults",
1475
- "Save and close",
1476
1523
  ]);
1477
1524
  });
1478
1525
 
1479
- test("notifications menu preserves focus on toggled and edited rows", async () => {
1480
- const cwd = createTempDir("pi-usereq-menu-focus-");
1526
+ test("show configuration saves pending config, closes the menu, and writes the persisted project config file text into the editor", async () => {
1527
+ const cwd = createTempDir("pi-usereq-menu-show-config-");
1481
1528
  fs.mkdirSync(path.dirname(getProjectConfigPath(cwd)), { recursive: true });
1482
1529
  const pi = createFakePi();
1483
1530
  piUsereqExtension(pi);
1484
1531
  const command = pi.commands.get("pi-usereq");
1485
1532
  assert.ok(command);
1486
1533
  const ctx = createFakeCtx(cwd, {
1487
- selects: [
1488
- "notifications",
1489
- "Notification events",
1490
- "Prompt failed",
1491
- "Save and close",
1492
- "Notify command",
1493
- "Save and close",
1494
- "Save and close",
1495
- ],
1496
- inputs: ["echo notify"],
1534
+ selects: ["docs-dir", "show-config"],
1535
+ inputs: ["docs/custom"],
1497
1536
  });
1498
1537
 
1499
1538
  await command!.handler("", ctx);
1500
1539
 
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");
1505
-
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");
1540
+ const persistedText = fs.readFileSync(getProjectConfigPath(cwd), "utf8");
1541
+ assert.equal(ctx.__state.editorText, persistedText);
1542
+ assert.match(ctx.__state.editorText, /"docs-dir": "docs\/custom"/);
1543
+ assert.equal(
1544
+ ctx.__state.selectCalls.filter((call) => call.title === "pi-usereq").length,
1545
+ 2,
1546
+ );
1516
1547
  });
1517
1548
 
1518
- test("notifications reset defaults preserves non-notification settings", async () => {
1519
- const cwd = createTempDir("pi-usereq-menu-reset-notifications-");
1549
+ test("descendant configuration menus end with Reset defaults only", async () => {
1550
+ const cwd = createTempDir("pi-usereq-menu-terminal-rows-");
1520
1551
  fs.mkdirSync(path.dirname(getProjectConfigPath(cwd)), { recursive: true });
1521
1552
  const pi = createFakePi();
1522
1553
  piUsereqExtension(pi);
@@ -1524,24 +1555,396 @@ test("notifications reset defaults preserves non-notification settings", async (
1524
1555
  assert.ok(command);
1525
1556
  const ctx = createFakeCtx(cwd, {
1526
1557
  selects: [
1527
- "docs-dir",
1528
1558
  "notifications",
1529
- "Enable notification",
1530
1559
  "Enable sound",
1531
- "high",
1532
- "Reset defaults",
1560
+ "Save and close",
1561
+ "Pushover priority",
1562
+ "Save and close",
1563
+ "Save and close",
1564
+ "Enable tools",
1565
+ "Enable tools",
1566
+ "Save and close",
1567
+ "Save and close",
1568
+ "Debug",
1569
+ "Save and close",
1570
+ "Language static code checkers",
1571
+ "Add static code checker",
1572
+ "Save and close",
1573
+ "Remove static code checker",
1574
+ "Save and close",
1575
+ "Save and close",
1576
+ "Source-code directories",
1577
+ "Remove source-code directory",
1578
+ "Save and close",
1533
1579
  "Save and close",
1534
1580
  "Save and close",
1535
1581
  ],
1536
- inputs: ["docs/custom"],
1537
1582
  });
1538
1583
 
1539
1584
  await command!.handler("", ctx);
1540
1585
 
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");
1586
+ const getMenuIndex = (title: string, marker?: string): number => {
1587
+ const index = ctx.__state.selectCalls.findIndex(
1588
+ (entry) => entry.title === title && (marker === undefined || entry.items.includes(marker)),
1589
+ );
1590
+ assert.notEqual(index, -1, `missing menu ${title}`);
1591
+ return index;
1592
+ };
1593
+ const getTerminalRows = (title: string, marker?: string): string[] => {
1594
+ const index = getMenuIndex(title, marker);
1595
+ return ctx.__state.selectCalls[index]!.items.slice(-1);
1596
+ };
1597
+ const getRenderedMenu = (title: string, marker?: string): string => {
1598
+ const index = getMenuIndex(title, marker);
1599
+ return (ctx.__state.customRenderLines[index] ?? []).join("\n");
1600
+ };
1601
+
1602
+ assert.deepEqual(getTerminalRows("Enable sound", "high"), ["Reset defaults"]);
1603
+ assert.deepEqual(getTerminalRows("Pushover priority", "High"), ["Reset defaults"]);
1604
+ assert.deepEqual(getTerminalRows("Enable tools", "static-check"), ["Reset defaults"]);
1605
+ assert.deepEqual(getTerminalRows("Debug", "Log on status"), ["Reset defaults"]);
1606
+ assert.deepEqual(getTerminalRows("static-check language", "Python"), ["Reset defaults"]);
1607
+ assert.deepEqual(getTerminalRows("Remove static code checker"), ["Reset defaults"]);
1608
+ assert.deepEqual(getTerminalRows("Remove source-code directory", "src"), ["Reset defaults"]);
1609
+ assert.doesNotMatch(getRenderedMenu("Enable sound", "high"), /Reset defaults.*none/);
1610
+ assert.doesNotMatch(getRenderedMenu("Pushover priority", "High"), /Reset defaults.*Normal/);
1611
+ assert.doesNotMatch(getRenderedMenu("Enable tools", "static-check"), /Reset defaults.*defaults/);
1612
+ assert.doesNotMatch(getRenderedMenu("Debug", "Log on status"), /Reset defaults.*running/);
1613
+ assert.doesNotMatch(getRenderedMenu("Remove source-code directory", "src"), /Reset defaults.*src/);
1614
+ });
1615
+
1616
+ test("debug menu dims locked rows and persists debug settings with focus-preserving re-renders", async () => {
1617
+ const cwd = createTempDir("pi-usereq-menu-debug-");
1618
+ fs.mkdirSync(path.dirname(getProjectConfigPath(cwd)), { recursive: true });
1619
+ const pi = createFakePi();
1620
+ piUsereqExtension(pi);
1621
+ const command = pi.commands.get("pi-usereq");
1622
+ assert.ok(command);
1623
+ const ctx = createFakeCtx(cwd, {
1624
+ selects: [
1625
+ "Debug",
1626
+ "Debug",
1627
+ "Log file",
1628
+ "Log on status",
1629
+ "any",
1630
+ "Status changes",
1631
+ "Workflow events",
1632
+ "files-tokens",
1633
+ "req-analyze",
1634
+ "Save and close",
1635
+ "Save and close",
1636
+ ],
1637
+ inputs: ["logs/debug-output.json"],
1638
+ });
1639
+
1640
+ await command!.handler("", ctx);
1641
+
1642
+ const renderedInitialDebugMenu = (ctx.__state.customRenderLines[1] ?? []).join("\n");
1643
+ const persistedConfig = JSON.parse(fs.readFileSync(getProjectConfigPath(cwd), "utf8"));
1644
+ const debugCalls = ctx.__state.selectCalls.filter((call) => call.title === "Debug");
1645
+ assert.match(renderedInitialDebugMenu, /<dim>Log file/);
1646
+ assert.match(renderedInitialDebugMenu, /<dim>Log on status/);
1647
+ assert.match(renderedInitialDebugMenu, /<dim>Status changes/);
1648
+ assert.match(renderedInitialDebugMenu, /<dim>Workflow events/);
1649
+ assert.match(renderedInitialDebugMenu, /<dim>compress/);
1650
+ assert.equal(persistedConfig.DEBUG_ENABLED, "enable");
1651
+ assert.equal(persistedConfig.DEBUG_LOG_FILE, "logs/debug-output.json");
1652
+ assert.equal(persistedConfig.DEBUG_STATUS_CHANGES, "enable");
1653
+ assert.equal(persistedConfig.DEBUG_WORKFLOW_EVENTS, "enable");
1654
+ assert.equal(persistedConfig.DEBUG_LOG_ON_STATUS, "any");
1655
+ assert.deepEqual(persistedConfig.DEBUG_ENABLED_TOOLS, ["files-tokens"]);
1656
+ assert.deepEqual(persistedConfig.DEBUG_ENABLED_PROMPTS, ["req-analyze"]);
1657
+ assert.equal(debugCalls[1]?.selectedChoiceId, "debug-enabled");
1658
+ assert.equal(debugCalls[2]?.selectedChoiceId, "debug-log-file");
1659
+ assert.equal(debugCalls[3]?.selectedChoiceId, "debug-log-on-status");
1660
+ assert.equal(debugCalls[4]?.selectedChoiceId, "debug-status-changes");
1661
+ assert.equal(debugCalls[5]?.selectedChoiceId, "debug-workflow-events");
1662
+ assert.equal(debugCalls[6]?.selectedChoiceId, "debug-tool:files-tokens");
1663
+ assert.equal(debugCalls[7]?.selectedChoiceId, "debug-prompt:req-analyze");
1664
+
1665
+ const resetCtx = createFakeCtx(cwd, {
1666
+ selects: ["Debug", "Reset defaults", "Approve reset", "Save and close"],
1667
+ });
1668
+ await command!.handler("", resetCtx);
1669
+ const resetConfig = JSON.parse(fs.readFileSync(getProjectConfigPath(cwd), "utf8"));
1670
+ assert.equal(resetConfig.DEBUG_ENABLED, "disable");
1671
+ assert.equal(resetConfig.DEBUG_LOG_FILE, DEFAULT_DEBUG_LOG_FILE);
1672
+ assert.equal(resetConfig.DEBUG_STATUS_CHANGES, "disable");
1673
+ assert.equal(resetConfig.DEBUG_WORKFLOW_EVENTS, DEFAULT_DEBUG_WORKFLOW_EVENTS);
1674
+ assert.equal(resetConfig.DEBUG_LOG_ON_STATUS, DEFAULT_DEBUG_LOG_ON_STATUS);
1675
+ assert.deepEqual(resetConfig.DEBUG_ENABLED_TOOLS, []);
1676
+ assert.deepEqual(resetConfig.DEBUG_ENABLED_PROMPTS, []);
1677
+ });
1678
+
1679
+ test("debug menu rows derive from canonical tool and prompt inventories", async () => {
1680
+ const cwd = createTempDir("pi-usereq-menu-debug-inventory-");
1681
+ fs.mkdirSync(path.dirname(getProjectConfigPath(cwd)), { recursive: true });
1682
+ const pi = createFakePi();
1683
+ piUsereqExtension(pi);
1684
+ const command = pi.commands.get("pi-usereq");
1685
+ assert.ok(command);
1686
+ const ctx = createFakeCtx(cwd, {
1687
+ selects: ["Debug", "Save and close"]
1688
+ });
1689
+
1690
+ await command!.handler("", ctx);
1691
+
1692
+ assert.deepEqual(ctx.__state.selectCalls[1]?.items ?? [], [
1693
+ "Debug",
1694
+ "Log file",
1695
+ "Log on status",
1696
+ "Status changes",
1697
+ "Workflow events",
1698
+ ...[
1699
+ ...PI_USEREQ_CUSTOM_TOOL_NAMES,
1700
+ ...PI_USEREQ_EMBEDDED_TOOL_NAMES,
1701
+ ].sort(comparePiUsereqStartupToolNames),
1702
+ ...DEBUG_PROMPT_NAMES,
1703
+ "Reset defaults",
1704
+ ]);
1705
+ assert.deepEqual(
1706
+ DEBUG_PROMPT_NAMES,
1707
+ PROMPT_COMMAND_NAMES.map((promptName) => `req-${promptName}`),
1708
+ );
1709
+ });
1710
+
1711
+ test("tool debug logging honors global enablement and workflow-status filters", async () => {
1712
+ const idleBase = createTempDir("pi-usereq-tool-debug-idle-");
1713
+ fs.mkdirSync(path.dirname(getProjectConfigPath(idleBase)), { recursive: true });
1714
+ fs.writeFileSync(
1715
+ getProjectConfigPath(idleBase),
1716
+ `${JSON.stringify({
1717
+ ...getDefaultConfig(idleBase),
1718
+ DEBUG_ENABLED: "enable",
1719
+ DEBUG_LOG_FILE: "tool-debug.json",
1720
+ DEBUG_LOG_ON_STATUS: "any",
1721
+ DEBUG_ENABLED_TOOLS: ["bash"],
1722
+ }, null, 2)}\n`,
1723
+ "utf8",
1724
+ );
1725
+ const idlePi = createFakePi();
1726
+ piUsereqExtension(idlePi);
1727
+ const idleCtx = createFakeCtx(idleBase);
1728
+ await idlePi.emit("session_start", { reason: "startup" }, idleCtx);
1729
+ await idlePi.emit("tool_result", {
1730
+ toolName: "bash",
1731
+ input: { command: "echo idle" },
1732
+ content: [{ type: "text", text: "idle" }],
1733
+ details: { stdout: "idle" },
1734
+ isError: false,
1735
+ }, idleCtx);
1736
+ const idleLog = JSON.parse(fs.readFileSync(path.join(idleBase, "tool-debug.json"), "utf8"));
1737
+ assert.equal(idleLog.length, 1);
1738
+ assert.equal(idleLog[0].category, "tool");
1739
+ assert.equal(idleLog[0].name, "bash");
1740
+ assert.equal(idleLog[0].workflow_state, "idle");
1741
+ assert.deepEqual(idleLog[0].input, { command: "echo idle" });
1742
+ assert.deepEqual(idleLog[0].result, {
1743
+ content: [{ type: "text", text: "idle" }],
1744
+ details: { stdout: "idle" },
1745
+ });
1746
+ assert.equal(idleLog[0].is_error, false);
1747
+
1748
+ const runningFixture = initFixtureRepo();
1749
+ writeProjectConfigOverrides(runningFixture.projectBase, {
1750
+ GIT_WORKTREE_ENABLED: "disable",
1751
+ DEBUG_ENABLED: "enable",
1752
+ DEBUG_LOG_FILE: "tool-debug.json",
1753
+ DEBUG_LOG_ON_STATUS: "running",
1754
+ DEBUG_ENABLED_TOOLS: ["files-tokens"],
1755
+ });
1756
+ const runningPi = createFakePi();
1757
+ piUsereqExtension(runningPi);
1758
+ const runningCtx = createFakeCtx(runningFixture.projectBase);
1759
+ await runningPi.emit("session_start", { reason: "startup" }, runningCtx);
1760
+ await runningPi.emit("tool_result", {
1761
+ toolName: "files-tokens",
1762
+ input: { files: ["src/fixture_python.py"] },
1763
+ content: [{ type: "text", text: "idle" }],
1764
+ details: { summary: { counted_file_count: 1 } },
1765
+ isError: false,
1766
+ }, runningCtx);
1767
+ assert.equal(fs.existsSync(path.join(runningFixture.projectBase, "tool-debug.json")), false);
1768
+ const runningCommand = runningPi.commands.get("req-implement");
1769
+ assert.ok(runningCommand);
1770
+ await runningCommand!.handler("debug tools", runningCtx);
1771
+ await runningPi.emit("tool_result", {
1772
+ toolName: "files-tokens",
1773
+ input: { files: ["src/fixture_python.py"] },
1774
+ content: [{ type: "text", text: "running" }],
1775
+ details: { summary: { counted_file_count: 1 } },
1776
+ isError: false,
1777
+ }, runningCtx);
1778
+ const runningLog = JSON.parse(fs.readFileSync(path.join(runningFixture.projectBase, "tool-debug.json"), "utf8"));
1779
+ assert.equal(runningLog.length, 1);
1780
+ assert.equal(runningLog[0].workflow_state, "running");
1781
+ assert.equal(runningLog[0].name, "files-tokens");
1782
+
1783
+ const disabledBase = createTempDir("pi-usereq-tool-debug-disabled-");
1784
+ fs.mkdirSync(path.dirname(getProjectConfigPath(disabledBase)), { recursive: true });
1785
+ fs.writeFileSync(
1786
+ getProjectConfigPath(disabledBase),
1787
+ `${JSON.stringify({
1788
+ ...getDefaultConfig(disabledBase),
1789
+ DEBUG_ENABLED: "disable",
1790
+ DEBUG_LOG_FILE: "tool-debug.json",
1791
+ DEBUG_LOG_ON_STATUS: "any",
1792
+ DEBUG_ENABLED_TOOLS: ["bash"],
1793
+ }, null, 2)}\n`,
1794
+ "utf8",
1795
+ );
1796
+ const disabledPi = createFakePi();
1797
+ piUsereqExtension(disabledPi);
1798
+ const disabledCtx = createFakeCtx(disabledBase);
1799
+ await disabledPi.emit("session_start", { reason: "startup" }, disabledCtx);
1800
+ await disabledPi.emit("tool_result", {
1801
+ toolName: "bash",
1802
+ input: { command: "echo disabled" },
1803
+ content: [{ type: "text", text: "disabled" }],
1804
+ details: { stdout: "disabled" },
1805
+ isError: false,
1806
+ }, disabledCtx);
1807
+ assert.equal(fs.existsSync(path.join(disabledBase, "tool-debug.json")), false);
1808
+ });
1809
+
1810
+ test("prompt debug logging captures failing and successful prompt orchestration entries", async () => {
1811
+ const failingFixture = initFixtureRepo();
1812
+ fs.rmSync(path.join(failingFixture.projectBase, DEFAULT_DOCS_DIR, "WORKFLOW.md"), { force: true });
1813
+ assert.equal(spawnSync("git", ["add", "-A"], { cwd: failingFixture.projectBase, encoding: "utf8" }).status, 0);
1814
+ assert.equal(
1815
+ spawnSync("git", ["commit", "-m", "remove workflow"], { cwd: failingFixture.projectBase, encoding: "utf8" }).status,
1816
+ 0,
1817
+ );
1818
+ writeProjectConfigOverrides(failingFixture.projectBase, {
1819
+ DEBUG_ENABLED: "enable",
1820
+ DEBUG_LOG_FILE: "prompt-debug.json",
1821
+ DEBUG_STATUS_CHANGES: "enable",
1822
+ DEBUG_WORKFLOW_EVENTS: "enable",
1823
+ DEBUG_LOG_ON_STATUS: "any",
1824
+ DEBUG_ENABLED_PROMPTS: ["req-change"],
1825
+ });
1826
+ const failingPi = createFakePi();
1827
+ piUsereqExtension(failingPi);
1828
+ const failingCtx = createFakeCtx(failingFixture.projectBase);
1829
+ await failingPi.emit("session_start", { reason: "startup" }, failingCtx);
1830
+ const failingCommand = failingPi.commands.get("req-change");
1831
+ assert.ok(failingCommand);
1832
+ await assert.rejects(async () => {
1833
+ await failingCommand!.handler("debug prompt", failingCtx);
1834
+ }, /Git status unclear|WORKFLOW\.md/);
1835
+ const failingLog = JSON.parse(fs.readFileSync(path.join(failingFixture.projectBase, "prompt-debug.json"), "utf8"));
1836
+ assert.ok(failingLog.some((entry: any) => entry.action === "workflow_state" && entry.result?.workflow_state === "error"));
1837
+
1838
+ const successFixture = initFixtureRepo();
1839
+ writeProjectConfigOverrides(successFixture.projectBase, {
1840
+ DEBUG_ENABLED: "enable",
1841
+ DEBUG_LOG_FILE: "prompt-debug.json",
1842
+ DEBUG_STATUS_CHANGES: "enable",
1843
+ DEBUG_WORKFLOW_EVENTS: "enable",
1844
+ DEBUG_LOG_ON_STATUS: "any",
1845
+ DEBUG_ENABLED_PROMPTS: ["req-implement"],
1846
+ });
1847
+ const successPi = createFakePi();
1848
+ piUsereqExtension(successPi);
1849
+ const successCtx = createFakeCtx(successFixture.projectBase);
1850
+ await successPi.emit("session_start", { reason: "startup" }, successCtx);
1851
+ const successCommand = successPi.commands.get("req-implement");
1852
+ assert.ok(successCommand);
1853
+ await successCommand!.handler("debug prompt", successCtx);
1854
+ const promptText = String(successPi.sentUserMessages.at(-1)?.content ?? "");
1855
+ const worktreeMatch = promptText.match(/created worktree-dir `([^`]+)` and prepared context-path `([^`]+)`\./);
1856
+ assert.ok(worktreeMatch, promptText);
1857
+ const executionBasePath = worktreeMatch?.[2] ?? "";
1858
+ await successPi.emit("before_agent_start", {}, successCtx);
1859
+ await successPi.emit("agent_start", {}, successCtx);
1860
+ fs.writeFileSync(path.join(executionBasePath, "src", "debug.ts"), "export const debug = true;\n", "utf8");
1861
+ assert.equal(spawnSync("git", ["add", "src/debug.ts"], { cwd: executionBasePath, encoding: "utf8" }).status, 0);
1862
+ const worktreeCommit = spawnSync("git", ["commit", "-m", "debug prompt"], {
1863
+ cwd: executionBasePath,
1864
+ encoding: "utf8",
1865
+ });
1866
+ assert.equal(worktreeCommit.status, 0, worktreeCommit.stderr);
1867
+ await successPi.emit("agent_end", {
1868
+ messages: [{ role: "assistant", stopReason: "end_turn" }],
1869
+ }, successCtx);
1870
+ const successLog = JSON.parse(fs.readFileSync(path.join(successFixture.projectBase, "prompt-debug.json"), "utf8"));
1871
+ assert.ok(successLog.some((entry: any) => entry.action === "required_docs_check" && entry.result?.success === true));
1872
+ assert.ok(successLog.some((entry: any) => entry.action === "worktree_create" && entry.result?.success === true));
1873
+ assert.ok(successLog.some((entry: any) => entry.action === "workflow_activation" && entry.result?.success === true));
1874
+ assert.ok(successLog.some((entry: any) => entry.action === "workflow_closure" && entry.result?.should_finalize_matched_success === true));
1875
+ assert.ok(successLog.some((entry: any) => entry.action === "workflow_restore" && entry.result?.success === true));
1876
+ assert.ok(successLog.some((entry: any) => entry.action === "merge" && entry.result?.success === true));
1877
+ assert.ok(successLog.some((entry: any) => entry.action === "worktree_delete" && entry.result?.success === true));
1878
+ assert.ok(successLog.some((entry: any) => entry.action === "workflow_state" && entry.result?.workflow_state === "merging"));
1879
+ assert.ok(successLog.some((entry: any) => entry.action === "workflow_state" && entry.result?.workflow_state === "idle"));
1880
+ });
1881
+
1882
+ test("notifications menu preserves focus on toggled and edited rows", async () => {
1883
+ const cwd = createTempDir("pi-usereq-menu-focus-");
1884
+ fs.mkdirSync(path.dirname(getProjectConfigPath(cwd)), { recursive: true });
1885
+ const pi = createFakePi();
1886
+ piUsereqExtension(pi);
1887
+ const command = pi.commands.get("pi-usereq");
1888
+ assert.ok(command);
1889
+ const ctx = createFakeCtx(cwd, {
1890
+ selects: [
1891
+ "notifications",
1892
+ "Notification events",
1893
+ "Prompt failed",
1894
+ "Save and close",
1895
+ "Notify command",
1896
+ "Save and close",
1897
+ "Save and close",
1898
+ ],
1899
+ inputs: ["echo notify"],
1900
+ });
1901
+
1902
+ await command!.handler("", ctx);
1903
+
1904
+ const notificationCalls = ctx.__state.selectCalls.filter((call) => call.title === "Notifications");
1905
+ assert.equal(notificationCalls[0]?.selectedChoiceId, "notify-enabled");
1906
+ assert.equal(notificationCalls[1]?.selectedChoiceId, "notification-events");
1907
+ assert.equal(notificationCalls[2]?.selectedChoiceId, "notify-command");
1908
+
1909
+ const notificationEventCalls = ctx.__state.selectCalls.filter((call) => call.title === "Notification events");
1910
+ assert.deepEqual(notificationEventCalls[0]?.items ?? [], [
1911
+ "Prompt completed",
1912
+ "Prompt interrupted",
1913
+ "Prompt failed",
1914
+ "Reset defaults",
1915
+ ]);
1916
+ assert.equal(notificationEventCalls[0]?.selectedChoiceId, "notify-on-completed");
1917
+ assert.equal(notificationEventCalls[1]?.selectedChoiceId, "notify-on-failed");
1918
+ });
1919
+
1920
+ test("notifications reset defaults preserves non-notification settings", async () => {
1921
+ const cwd = createTempDir("pi-usereq-menu-reset-notifications-");
1922
+ fs.mkdirSync(path.dirname(getProjectConfigPath(cwd)), { recursive: true });
1923
+ const pi = createFakePi();
1924
+ piUsereqExtension(pi);
1925
+ const command = pi.commands.get("pi-usereq");
1926
+ assert.ok(command);
1927
+ const ctx = createFakeCtx(cwd, {
1928
+ selects: [
1929
+ "docs-dir",
1930
+ "notifications",
1931
+ "Enable notification",
1932
+ "Enable sound",
1933
+ "high",
1934
+ "Reset defaults",
1935
+ "Approve reset",
1936
+ "Save and close",
1937
+ "Save and close",
1938
+ ],
1939
+ inputs: ["docs/custom"],
1940
+ });
1941
+
1942
+ await command!.handler("", ctx);
1943
+
1944
+ const config = JSON.parse(fs.readFileSync(getProjectConfigPath(cwd), "utf8"));
1945
+ assert.equal(config["docs-dir"], "docs/custom");
1946
+ assert.equal(config["notify-enabled"], false);
1947
+ assert.equal(config["notify-sound"], "none");
1545
1948
  assert.equal(config["notify-pushover-enabled"], false);
1546
1949
  });
1547
1950
 
@@ -1559,69 +1962,1978 @@ test("top-level reset defaults restores all configuration values", async () => {
1559
1962
  "Enable notification",
1560
1963
  "Save and close",
1561
1964
  "Reset defaults",
1965
+ "Approve reset",
1562
1966
  "Save and close",
1563
1967
  ],
1564
1968
  inputs: ["docs/custom"],
1565
1969
  });
1566
1970
 
1567
- await command!.handler("", ctx);
1971
+ await command!.handler("", ctx);
1972
+
1973
+ const config = JSON.parse(fs.readFileSync(getProjectConfigPath(cwd), "utf8"));
1974
+ assert.equal(config["docs-dir"], DEFAULT_DOCS_DIR);
1975
+ assert.deepEqual(config["src-dir"], ["src"]);
1976
+ assert.equal(config["notify-enabled"], false);
1977
+ assert.equal(config["notify-sound"], "none");
1978
+ });
1979
+
1980
+ test("configuration menus reuse CLI settings theme semantics", async () => {
1981
+ const cwd = createTempDir("pi-usereq-menu-theme-");
1982
+ fs.mkdirSync(path.dirname(getProjectConfigPath(cwd)), { recursive: true });
1983
+ const usedColors = new Set<string>();
1984
+ const pi = createFakePi();
1985
+ piUsereqExtension(pi);
1986
+ const command = pi.commands.get("pi-usereq");
1987
+ assert.ok(command);
1988
+ const ctx = createFakeCtx(cwd, { selects: [] }, {
1989
+ theme: {
1990
+ fg(color: string, text: string) {
1991
+ usedColors.add(color);
1992
+ return formatFakeThemeForeground(color, text);
1993
+ },
1994
+ bgFromFg(color: string, text: string) {
1995
+ assert.ok(["accent", "muted", "dim"].includes(color));
1996
+ return formatFakeThemeBackgroundFromForeground(color, text);
1997
+ },
1998
+ bold(text: string) {
1999
+ return `<bold>${text}</bold>`;
2000
+ },
2001
+ },
2002
+ });
2003
+
2004
+ await command!.handler("", ctx);
2005
+
2006
+ const renderedMenu = (ctx.__state.customRenderLines[0] ?? []).join("\n");
2007
+ assert.match(renderedMenu, /<accent><bold>pi-usereq<\/bold><\/accent>/);
2008
+ assert.match(renderedMenu, /<accent>→ <\/accent>/);
2009
+ assert.match(renderedMenu, /<accent>pi-usereq\/docs<\/accent>/);
2010
+ assert.match(renderedMenu, /<muted>tests<\/muted>/);
2011
+ assert.match(renderedMenu, /<dim>/);
2012
+ assert.deepEqual([...usedColors].sort(), ["accent", "dim", "muted"]);
2013
+ });
2014
+
2015
+ test("prompt commands use the current session by default", async () => {
2016
+ const { projectBase } = initFixtureRepo({ fixtures: [] });
2017
+ try {
2018
+ writeProjectConfigOverrides(projectBase, { GIT_WORKTREE_ENABLED: "disable" });
2019
+ const pi = createFakePi();
2020
+ piUsereqExtension(pi);
2021
+ const command = pi.commands.get("req-analyze");
2022
+ assert.ok(command);
2023
+ const ctx = createFakeCtx(projectBase);
2024
+
2025
+ await command!.handler("Inspect src/index.ts for prompt coverage", ctx);
2026
+
2027
+ assert.equal(ctx.__state.waitForIdleCalls, 0);
2028
+ assert.equal(ctx.__state.newSessions.length, 0);
2029
+ assert.equal(pi.sentUserMessages.length, 1);
2030
+ assert.match(String(pi.sentUserMessages[0]?.content), /Inspect src\/index\.ts for prompt coverage/);
2031
+ } finally {
2032
+ fs.rmSync(projectBase, { recursive: true, force: true });
2033
+ }
2034
+ });
2035
+
2036
+ test("prompt commands skip worktree creation when auto git commit is disabled", async () => {
2037
+ const { projectBase } = initFixtureRepo({ fixtures: [] });
2038
+ try {
2039
+ writeProjectConfigOverrides(projectBase, {
2040
+ AUTO_GIT_COMMIT: "disable",
2041
+ GIT_WORKTREE_ENABLED: "enable",
2042
+ });
2043
+ const pi = createFakePi();
2044
+ piUsereqExtension(pi);
2045
+ const command = pi.commands.get("req-change");
2046
+ assert.ok(command);
2047
+ const ctx = createFakeCtx(projectBase);
2048
+
2049
+ await command!.handler("Adjust docs", ctx);
2050
+
2051
+ const promptText = String(pi.sentUserMessages[0]?.content ?? "");
2052
+ const expectedBasePath = buildExpectedFakeBasePath(projectBase).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
2053
+ assert.doesNotMatch(promptText, /created worktree/);
2054
+ assert.match(
2055
+ promptText,
2056
+ new RegExp(`worktree orchestration is disabled and context-path remains \`${expectedBasePath}\``),
2057
+ );
2058
+ } finally {
2059
+ fs.rmSync(projectBase, { recursive: true, force: true });
2060
+ }
2061
+ });
2062
+
2063
+ test("prompt commands capture checking before handoff and running at prompt injection", async () => {
2064
+ const { projectBase } = initFixtureRepo({ fixtures: [] });
2065
+ const previousCwd = process.cwd();
2066
+ let checkingStatus = "";
2067
+ let handoffStatus = "";
2068
+ try {
2069
+ const pi = createFakePi();
2070
+ piUsereqExtension(pi);
2071
+ const ctx = createFakeCtx(projectBase);
2072
+ await pi.emit("session_start", { reason: "startup" }, ctx);
2073
+ const originalSendUserMessage = pi.sendUserMessage.bind(pi);
2074
+ pi.sendUserMessage = (content: unknown, options?: unknown) => {
2075
+ handoffStatus = ctx.__state.statuses.get("pi-usereq") ?? "";
2076
+ originalSendUserMessage(content, options);
2077
+ };
2078
+ setPromptCommandPostCreateHookForTests(() => {
2079
+ checkingStatus = ctx.__state.statuses.get("pi-usereq") ?? "";
2080
+ });
2081
+
2082
+ await pi.commands.get("req-change")!.handler("Adjust docs", ctx);
2083
+
2084
+ const promptText = String(pi.sentUserMessages[0]?.content ?? "");
2085
+ const worktreeMatch = promptText.match(/created worktree-dir `([^`]+)` and prepared context-path `([^`]+)`\./);
2086
+ assert.ok(worktreeMatch, promptText);
2087
+ const executionBasePath = worktreeMatch?.[2] ?? "";
2088
+ assert.equal(
2089
+ checkingStatus,
2090
+ buildExpectedFakeStatusText({
2091
+ workflowState: "checking",
2092
+ basePath: buildExpectedFakeBasePath(projectBase),
2093
+ docsDir: DEFAULT_DOCS_DIR,
2094
+ testsDir: "tests",
2095
+ srcDir: ["src"],
2096
+ contextFilledCells: 0,
2097
+ et: "⏱︎ --:-- ⚑ --:-- ⌛︎--:--",
2098
+ }),
2099
+ );
2100
+ assert.equal(
2101
+ handoffStatus,
2102
+ buildExpectedFakeStatusText({
2103
+ workflowState: "checking",
2104
+ basePath: buildExpectedFakeBasePath(executionBasePath),
2105
+ docsDir: DEFAULT_DOCS_DIR,
2106
+ testsDir: "tests",
2107
+ srcDir: ["src"],
2108
+ contextFilledCells: 0,
2109
+ et: "⏱︎ --:-- ⚑ --:-- ⌛︎--:--",
2110
+ }),
2111
+ );
2112
+ assert.equal(
2113
+ ctx.__state.statuses.get("pi-usereq"),
2114
+ buildExpectedFakeStatusText({
2115
+ workflowState: "running",
2116
+ basePath: buildExpectedFakeBasePath(executionBasePath),
2117
+ docsDir: DEFAULT_DOCS_DIR,
2118
+ testsDir: "tests",
2119
+ srcDir: ["src"],
2120
+ contextFilledCells: 0,
2121
+ et: "⏱︎ --:-- ⚑ --:-- ⌛︎--:--",
2122
+ }),
2123
+ );
2124
+
2125
+ await pi.emit("before_agent_start", {}, ctx);
2126
+ await pi.emit("agent_start", {}, ctx);
2127
+ await pi.emit("agent_end", {
2128
+ messages: [{ role: "assistant", stopReason: "stop", content: [] }],
2129
+ }, ctx);
2130
+ } finally {
2131
+ setPromptCommandPostCreateHookForTests(undefined);
2132
+ process.chdir(previousCwd);
2133
+ fs.rmSync(projectBase, { recursive: true, force: true });
2134
+ }
2135
+ });
2136
+
2137
+ test("prompt commands reject non-idle workflow state before starting a new orchestration", async () => {
2138
+ const { projectBase } = initFixtureRepo({ fixtures: [] });
2139
+ try {
2140
+ writeProjectConfigOverrides(projectBase, { GIT_WORKTREE_ENABLED: "disable" });
2141
+ const pi = createFakePi();
2142
+ piUsereqExtension(pi);
2143
+ const ctx = createFakeCtx(projectBase);
2144
+ await pi.emit("session_start", { reason: "startup" }, ctx);
2145
+
2146
+ await pi.commands.get("req-change")!.handler("Adjust docs", ctx);
2147
+ assert.equal(
2148
+ ctx.__state.statuses.get("pi-usereq"),
2149
+ buildExpectedFakeStatusText({
2150
+ workflowState: "running",
2151
+ basePath: buildExpectedFakeBasePath(projectBase),
2152
+ docsDir: DEFAULT_DOCS_DIR,
2153
+ testsDir: "tests",
2154
+ srcDir: ["src"],
2155
+ contextFilledCells: 0,
2156
+ et: "⏱︎ --:-- ⚑ --:-- ⌛︎--:--",
2157
+ }),
2158
+ );
2159
+
2160
+ await assert.rejects(
2161
+ pi.commands.get("req-analyze")!.handler("Inspect src/index.ts", ctx),
2162
+ /expected idle/,
2163
+ );
2164
+ assert.equal(pi.sentUserMessages.length, 1);
2165
+ assert.ok(ctx.__state.notifications.some((entry) => /expected idle/.test(entry.message)));
2166
+ assert.equal(
2167
+ ctx.__state.statuses.get("pi-usereq"),
2168
+ buildExpectedFakeStatusText({
2169
+ workflowState: "running",
2170
+ basePath: buildExpectedFakeBasePath(projectBase),
2171
+ docsDir: DEFAULT_DOCS_DIR,
2172
+ testsDir: "tests",
2173
+ srcDir: ["src"],
2174
+ contextFilledCells: 0,
2175
+ et: "⏱︎ --:-- ⚑ --:-- ⌛︎--:--",
2176
+ }),
2177
+ );
2178
+
2179
+ await pi.emit("agent_start", {}, ctx);
2180
+ await pi.emit("agent_end", {
2181
+ messages: [{ role: "assistant", stopReason: "stop", content: [] }],
2182
+ }, ctx);
2183
+ assert.equal(
2184
+ ctx.__state.statuses.get("pi-usereq"),
2185
+ buildExpectedFakeStatusText({
2186
+ workflowState: "idle",
2187
+ basePath: buildExpectedFakeBasePath(projectBase),
2188
+ docsDir: DEFAULT_DOCS_DIR,
2189
+ testsDir: "tests",
2190
+ srcDir: ["src"],
2191
+ contextFilledCells: 0,
2192
+ et: "⏱︎ --:-- ⚑ 0:00 ⌛︎0:00",
2193
+ }),
2194
+ );
2195
+ } finally {
2196
+ fs.rmSync(projectBase, { recursive: true, force: true });
2197
+ }
2198
+ });
2199
+
2200
+ test("prompt commands abort when git validation fails", async () => {
2201
+ const { projectBase } = initFixtureRepo({ fixtures: [] });
2202
+ const previousCwd = process.cwd();
2203
+ try {
2204
+ fs.writeFileSync(path.join(projectBase, "DIRTY.txt"), "dirty\n", "utf8");
2205
+ const pi = createFakePi();
2206
+ piUsereqExtension(pi);
2207
+ const ctx = createFakeCtx(projectBase);
2208
+ await pi.emit("session_start", { reason: "startup" }, ctx);
2209
+
2210
+ await assert.rejects(
2211
+ pi.commands.get("req-analyze")!.handler("Inspect src/index.ts", ctx),
2212
+ /ERROR: Git status unclear!/,
2213
+ );
2214
+ assert.equal(pi.sentUserMessages.length, 0);
2215
+ assert.ok(ctx.__state.notifications.some((entry) => /ERROR: Git status unclear!/.test(entry.message)));
2216
+ assert.equal(
2217
+ ctx.__state.statuses.get("pi-usereq"),
2218
+ buildExpectedFakeStatusText({
2219
+ workflowState: "error",
2220
+ basePath: buildExpectedFakeBasePath(projectBase),
2221
+ docsDir: DEFAULT_DOCS_DIR,
2222
+ testsDir: "tests",
2223
+ srcDir: ["src"],
2224
+ contextFilledCells: 0,
2225
+ et: "⏱︎ --:-- ⚑ --:-- ⌛︎--:--",
2226
+ }),
2227
+ );
2228
+ } finally {
2229
+ process.chdir(previousCwd);
2230
+ fs.rmSync(projectBase, { recursive: true, force: true });
2231
+ }
2232
+ });
2233
+
2234
+ test("prompt commands enforce the documented required-doc matrix", async () => {
2235
+ const { projectBase } = initFixtureRepo({
2236
+ fixtures: [],
2237
+ docs: {
2238
+ "REQUIREMENTS.md": "# Requirements\n",
2239
+ },
2240
+ });
2241
+ const previousCwd = process.cwd();
2242
+ try {
2243
+ const worktreeParentPath = path.dirname(projectBase);
2244
+ const initialEntries = new Set(fs.readdirSync(worktreeParentPath));
2245
+ const pi = createFakePi();
2246
+ piUsereqExtension(pi);
2247
+ const ctx = createFakeCtx(projectBase);
2248
+
2249
+ await assert.rejects(
2250
+ pi.commands.get("req-change")!.handler("Adjust docs", ctx),
2251
+ /WORKFLOW\.md/,
2252
+ );
2253
+ const createdWorktreeEntries = fs.readdirSync(worktreeParentPath)
2254
+ .filter((entry) => !initialEntries.has(entry))
2255
+ .filter((entry) => /^PI-useReq-/.test(entry));
2256
+ assert.equal(pi.sentUserMessages.length, 0);
2257
+ assert.deepEqual(createdWorktreeEntries, []);
2258
+ assert.ok(ctx.__state.notifications.some((entry) => /WORKFLOW\.md/.test(entry.message)));
2259
+ } finally {
2260
+ process.chdir(previousCwd);
2261
+ fs.rmSync(projectBase, { recursive: true, force: true });
2262
+ }
2263
+ });
2264
+
2265
+ test("worktree-enabled prompt commands merge successful runs, restore base-path, and delete the worktree", async () => {
2266
+ const { projectBase } = initFixtureRepo({ fixtures: [] });
2267
+ const previousCwd = process.cwd();
2268
+ try {
2269
+ const pi = createFakePi();
2270
+ piUsereqExtension(pi);
2271
+ const ctx = createFakeCtx(projectBase);
2272
+ const recordedStatuses: string[] = [];
2273
+ const originalSetStatus = ctx.ui.setStatus.bind(ctx.ui);
2274
+ ctx.ui.setStatus = (key: string, value?: string) => {
2275
+ if (key === "pi-usereq" && value !== undefined) {
2276
+ recordedStatuses.push(value);
2277
+ }
2278
+ originalSetStatus(key, value);
2279
+ };
2280
+ await pi.emit("session_start", { reason: "startup" }, ctx);
2281
+
2282
+ const originalBranchName = buildExpectedFakeBranchValue(projectBase);
2283
+ await pi.commands.get("req-change")!.handler("Adjust docs", ctx);
2284
+ const promptText = String(pi.sentUserMessages[0]?.content ?? "");
2285
+ const worktreeMatch = promptText.match(/created worktree-dir `([^`]+)` and prepared context-path `([^`]+)`\./);
2286
+ assert.ok(worktreeMatch, promptText);
2287
+ const worktreeName = worktreeMatch?.[1] ?? "";
2288
+ const executionBasePath = worktreeMatch?.[2] ?? "";
2289
+ assert.match(worktreeName, /^PI-useReq-/);
2290
+ assert.ok(fs.existsSync(executionBasePath));
2291
+ assert.equal(
2292
+ spawnSync("git", ["show-ref", "--verify", "--quiet", `refs/heads/${worktreeName}`], {
2293
+ cwd: projectBase,
2294
+ encoding: "utf8",
2295
+ }).status,
2296
+ 0,
2297
+ );
2298
+ assert.equal(process.cwd(), executionBasePath);
2299
+ assert.equal(ctx.cwd, executionBasePath);
2300
+ assert.ok(recordedStatuses.some((status) => /<accent>status:<\/accent><warning>running<\/warning>/.test(status)));
2301
+ assert.ok(recordedStatuses.some((status) => status.includes(`${formatFakeThemeForeground("accent", "branch:")}${formatFakeThemeForeground("warning", worktreeName)}`)));
2302
+
2303
+ await pi.emit("before_agent_start", {}, ctx);
2304
+ assert.equal(process.cwd(), executionBasePath);
2305
+ const filesTokensTool = pi.tools.find((candidate: RegisteredTool) => candidate.name === "files-tokens");
2306
+ assert.ok(filesTokensTool?.execute, "missing files-tokens execute handler");
2307
+ fs.mkdirSync(path.join(executionBasePath, "src"), { recursive: true });
2308
+ fs.writeFileSync(path.join(executionBasePath, "src", "tool-relative.ts"), "export const TOOL_RELATIVE = 1;\n", "utf8");
2309
+ const toolResult = await filesTokensTool.execute!("tool-call-id", { files: ["src/tool-relative.ts"] }, undefined, undefined, ctx) as {
2310
+ content?: Array<{ type?: string; text?: string }>;
2311
+ };
2312
+ assert.match(toolResult.content?.[0]?.text ?? "", /tool-relative\.ts/);
2313
+ await pi.emit("agent_start", {}, ctx);
2314
+ fs.writeFileSync(path.join(executionBasePath, "src", "orchestrated.ts"), "export const ORCHESTRATED = 1;\n", "utf8");
2315
+ assert.equal(spawnSync("git", ["add", "src/orchestrated.ts"], { cwd: executionBasePath, encoding: "utf8" }).status, 0);
2316
+ const commit = spawnSync("git", ["commit", "-m", "worktree change"], { cwd: executionBasePath, encoding: "utf8" });
2317
+ assert.equal(commit.status, 0, commit.stderr);
2318
+
2319
+ await pi.emit("agent_end", {
2320
+ messages: [{ role: "assistant", stopReason: "stop", content: [] }],
2321
+ }, ctx);
2322
+
2323
+ assert.equal(process.cwd(), projectBase);
2324
+ assert.equal(ctx.cwd, projectBase);
2325
+ assert.ok(fs.existsSync(path.join(projectBase, "src", "orchestrated.ts")));
2326
+ assert.equal(fs.existsSync(executionBasePath), false);
2327
+ assert.notEqual(
2328
+ spawnSync("git", ["show-ref", "--verify", "--quiet", `refs/heads/${worktreeName}`], {
2329
+ cwd: projectBase,
2330
+ encoding: "utf8",
2331
+ }).status,
2332
+ 0,
2333
+ );
2334
+ assert.equal(ctx.__state.notifications.filter((entry) => entry.level === "error").length, 0);
2335
+ assert.ok(recordedStatuses.some((status) => /<accent>status:<\/accent><warning>merging<\/warning>/.test(status)));
2336
+ assert.ok(recordedStatuses.some((status) => status.includes(`${formatFakeThemeForeground("accent", "branch:")}${formatFakeThemeForeground("warning", originalBranchName)}`)));
2337
+ const finalStatus = ctx.__state.statuses.get("pi-usereq") ?? "";
2338
+ assert.match(finalStatus, /<accent>status:<\/accent><warning>idle<\/warning>/);
2339
+ assert.ok(finalStatus.includes(`${formatFakeThemeForeground("accent", "branch:")}${formatFakeThemeForeground("warning", originalBranchName)}`));
2340
+ } finally {
2341
+ process.chdir(previousCwd);
2342
+ fs.rmSync(projectBase, { recursive: true, force: true });
2343
+ }
2344
+ });
2345
+
2346
+ /**
2347
+ * @brief Verifies prompt-command bootstrap ignores stale deleted-worktree session metadata on later commands.
2348
+ * @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.
2349
+ * @return {Promise<void>} Promise resolved after two successful prompt-command runs complete.
2350
+ * @throws {AssertionError} Throws when the second command aborts with the canonical git-status preflight error or reuses the deleted worktree context.
2351
+ * @satisfies REQ-200, REQ-206, REQ-208, REQ-219, REQ-257
2352
+ */
2353
+ test("prompt-command bootstrap ignores stale deleted-worktree session metadata on later commands", async () => {
2354
+ const { projectBase } = initFixtureRepo({ fixtures: [] });
2355
+ const previousCwd = process.cwd();
2356
+ try {
2357
+ const pi = createFakePi();
2358
+ piUsereqExtension(pi);
2359
+ const ctx = createFakeCtx(projectBase);
2360
+ await pi.emit("session_start", { reason: "startup" }, ctx);
2361
+
2362
+ await pi.commands.get("req-change")!.handler("Adjust docs", ctx);
2363
+ const firstPromptText = String(pi.sentUserMessages[0]?.content ?? "");
2364
+ const firstWorktreeMatch = firstPromptText.match(/created worktree-dir `([^`]+)` and prepared context-path `([^`]+)`\./);
2365
+ assert.ok(firstWorktreeMatch, firstPromptText);
2366
+ const firstWorktreeName = firstWorktreeMatch?.[1] ?? "";
2367
+ const firstExecutionBasePath = firstWorktreeMatch?.[2] ?? "";
2368
+ const firstExecutionSessionFile = ctx.sessionManager.getSessionFile() ?? "";
2369
+ assert.notEqual(firstExecutionSessionFile, "");
2370
+
2371
+ await pi.emit("before_agent_start", {}, ctx);
2372
+ await pi.emit("agent_start", {}, ctx);
2373
+ fs.mkdirSync(path.join(firstExecutionBasePath, "src"), { recursive: true });
2374
+ fs.writeFileSync(path.join(firstExecutionBasePath, "src", "bootstrap-first.ts"), "export const BOOTSTRAP_FIRST = 1;\n", "utf8");
2375
+ assert.equal(
2376
+ spawnSync("git", ["add", "src/bootstrap-first.ts"], {
2377
+ cwd: firstExecutionBasePath,
2378
+ encoding: "utf8",
2379
+ }).status,
2380
+ 0,
2381
+ );
2382
+ const firstCommit = spawnSync("git", ["commit", "-m", "bootstrap first"], {
2383
+ cwd: firstExecutionBasePath,
2384
+ encoding: "utf8",
2385
+ });
2386
+ assert.equal(firstCommit.status, 0, firstCommit.stderr);
2387
+
2388
+ await pi.emit("agent_end", {
2389
+ messages: [{ role: "assistant", stopReason: "stop", content: [] }],
2390
+ }, ctx);
2391
+
2392
+ assert.equal(process.cwd(), projectBase);
2393
+ assert.equal(ctx.cwd, projectBase);
2394
+ assert.equal(fs.existsSync(firstExecutionBasePath), false);
2395
+ assert.notEqual(
2396
+ spawnSync("git", ["show-ref", "--verify", "--quiet", `refs/heads/${firstWorktreeName}`], {
2397
+ cwd: projectBase,
2398
+ encoding: "utf8",
2399
+ }).status,
2400
+ 0,
2401
+ );
2402
+
2403
+ ctx.cwd = firstExecutionBasePath;
2404
+ ctx.sessionManager.getCwd = () => firstExecutionBasePath;
2405
+ ctx.sessionManager.getSessionFile = () => firstExecutionSessionFile;
2406
+ ctx.sessionManager.getSessionDir = () => path.dirname(firstExecutionSessionFile);
2407
+
2408
+ await pi.commands.get("req-change")!.handler("Adjust docs again", ctx);
2409
+ const secondPromptText = String(pi.sentUserMessages[1]?.content ?? "");
2410
+ const secondWorktreeMatch = secondPromptText.match(/created worktree-dir `([^`]+)` and prepared context-path `([^`]+)`\./);
2411
+ assert.ok(secondWorktreeMatch, secondPromptText);
2412
+ const secondWorktreeName = secondWorktreeMatch?.[1] ?? "";
2413
+ const secondExecutionBasePath = secondWorktreeMatch?.[2] ?? "";
2414
+
2415
+ assert.notEqual(secondWorktreeName, firstWorktreeName);
2416
+ assert.notEqual(secondExecutionBasePath, firstExecutionBasePath);
2417
+ assert.equal(process.cwd(), secondExecutionBasePath);
2418
+ assert.equal(ctx.cwd, secondExecutionBasePath);
2419
+
2420
+ await pi.emit("before_agent_start", {}, ctx);
2421
+ await pi.emit("agent_start", {}, ctx);
2422
+ fs.mkdirSync(path.join(secondExecutionBasePath, "src"), { recursive: true });
2423
+ fs.writeFileSync(path.join(secondExecutionBasePath, "src", "bootstrap-second.ts"), "export const BOOTSTRAP_SECOND = 1;\n", "utf8");
2424
+ assert.equal(
2425
+ spawnSync("git", ["add", "src/bootstrap-second.ts"], {
2426
+ cwd: secondExecutionBasePath,
2427
+ encoding: "utf8",
2428
+ }).status,
2429
+ 0,
2430
+ );
2431
+ const secondCommit = spawnSync("git", ["commit", "-m", "bootstrap second"], {
2432
+ cwd: secondExecutionBasePath,
2433
+ encoding: "utf8",
2434
+ });
2435
+ assert.equal(secondCommit.status, 0, secondCommit.stderr);
2436
+
2437
+ await pi.emit("agent_end", {
2438
+ messages: [{ role: "assistant", stopReason: "stop", content: [] }],
2439
+ }, ctx);
2440
+
2441
+ assert.equal(process.cwd(), projectBase);
2442
+ assert.equal(ctx.cwd, projectBase);
2443
+ assert.equal(fs.existsSync(secondExecutionBasePath), false);
2444
+ assert.ok(fs.existsSync(path.join(projectBase, "src", "bootstrap-first.ts")));
2445
+ assert.ok(fs.existsSync(path.join(projectBase, "src", "bootstrap-second.ts")));
2446
+ assert.notEqual(
2447
+ spawnSync("git", ["show-ref", "--verify", "--quiet", `refs/heads/${secondWorktreeName}`], {
2448
+ cwd: projectBase,
2449
+ encoding: "utf8",
2450
+ }).status,
2451
+ 0,
2452
+ );
2453
+ assert.equal(ctx.__state.notifications.filter((entry) => entry.level === "error").length, 0);
2454
+ } finally {
2455
+ process.chdir(previousCwd);
2456
+ fs.rmSync(projectBase, { recursive: true, force: true });
2457
+ }
2458
+ });
2459
+
2460
+ test("worktree-backed finalization transitions through merging, error, and idle when merge fails", async () => {
2461
+ const { projectBase } = initFixtureRepo({ fixtures: [] });
2462
+ const previousCwd = process.cwd();
2463
+ try {
2464
+ const pi = createFakePi();
2465
+ piUsereqExtension(pi);
2466
+ const ctx = createFakeCtx(projectBase);
2467
+ const recordedStatuses: string[] = [];
2468
+ const originalSetStatus = ctx.ui.setStatus.bind(ctx.ui);
2469
+ ctx.ui.setStatus = (key: string, value?: string) => {
2470
+ if (key === "pi-usereq" && value !== undefined) {
2471
+ recordedStatuses.push(value);
2472
+ }
2473
+ originalSetStatus(key, value);
2474
+ };
2475
+ await pi.emit("session_start", { reason: "startup" }, ctx);
2476
+
2477
+ await pi.commands.get("req-change")!.handler("Adjust docs", ctx);
2478
+ const promptText = String(pi.sentUserMessages[0]?.content ?? "");
2479
+ const worktreeMatch = promptText.match(/created worktree-dir `([^`]+)` and prepared context-path `([^`]+)`\./);
2480
+ assert.ok(worktreeMatch, promptText);
2481
+ const worktreeName = worktreeMatch?.[1] ?? "";
2482
+ const executionBasePath = worktreeMatch?.[2] ?? "";
2483
+
2484
+ await pi.emit("before_agent_start", {}, ctx);
2485
+ await pi.emit("agent_start", {}, ctx);
2486
+ fs.writeFileSync(path.join(projectBase, "divergent.txt"), "root\n", "utf8");
2487
+ assert.equal(spawnSync("git", ["add", "divergent.txt"], { cwd: projectBase, encoding: "utf8" }).status, 0);
2488
+ const rootCommit = spawnSync("git", ["commit", "-m", "root change"], { cwd: projectBase, encoding: "utf8" });
2489
+ assert.equal(rootCommit.status, 0, rootCommit.stderr);
2490
+ fs.mkdirSync(path.join(executionBasePath, "src"), { recursive: true });
2491
+ fs.writeFileSync(path.join(executionBasePath, "src", "merge-failed.ts"), "export const MERGE_FAILED = 1;\n", "utf8");
2492
+ assert.equal(spawnSync("git", ["add", "src/merge-failed.ts"], { cwd: executionBasePath, encoding: "utf8" }).status, 0);
2493
+ const worktreeCommit = spawnSync("git", ["commit", "-m", "worktree change"], { cwd: executionBasePath, encoding: "utf8" });
2494
+ assert.equal(worktreeCommit.status, 0, worktreeCommit.stderr);
2495
+
2496
+ await pi.emit("agent_end", {
2497
+ messages: [{ role: "assistant", stopReason: "stop", content: [] }],
2498
+ }, ctx);
2499
+
2500
+ assert.equal(process.cwd(), projectBase);
2501
+ assert.equal(ctx.cwd, projectBase);
2502
+ assert.equal(fs.existsSync(executionBasePath), true);
2503
+ assert.equal(fs.existsSync(path.join(projectBase, "src", "merge-failed.ts")), false);
2504
+ assert.ok(fs.existsSync(path.join(projectBase, "divergent.txt")));
2505
+ assert.equal(
2506
+ spawnSync("git", ["show-ref", "--verify", "--quiet", `refs/heads/${worktreeName}`], {
2507
+ cwd: projectBase,
2508
+ encoding: "utf8",
2509
+ }).status,
2510
+ 0,
2511
+ );
2512
+ assert.ok(recordedStatuses.some((status) => /<accent>status:<\/accent><warning>merging<\/warning>/.test(status)));
2513
+ assert.ok(recordedStatuses.some((status) => /<accent>status:<\/accent><error>\u001b\[5merror\u001b\[25m<\/error>/.test(status)));
2514
+ assert.match(ctx.__state.statuses.get("pi-usereq") ?? "", /<accent>status:<\/accent><warning>idle<\/warning>/);
2515
+ assert.ok(ctx.__state.notifications.some((entry) => /Fast-forward merge failed/.test(entry.message)));
2516
+ } finally {
2517
+ process.chdir(previousCwd);
2518
+ fs.rmSync(projectBase, { recursive: true, force: true });
2519
+ }
2520
+ });
2521
+
2522
+ test("worktree-enabled prompt commands honor overridden worktree prefixes", async () => {
2523
+ const { projectBase } = initFixtureRepo({ fixtures: [] });
2524
+ const previousCwd = process.cwd();
2525
+ try {
2526
+ writeProjectConfigOverrides(projectBase, { GIT_WORKTREE_PREFIX: "custom-prefix-" });
2527
+ const pi = createFakePi();
2528
+ piUsereqExtension(pi);
2529
+ const ctx = createFakeCtx(projectBase);
2530
+
2531
+ await pi.commands.get("req-change")!.handler("Adjust docs", ctx);
2532
+ const promptText = String(pi.sentUserMessages[0]?.content ?? "");
2533
+ const worktreeMatch = promptText.match(/created worktree-dir `([^`]+)` and prepared context-path `([^`]+)`\./);
2534
+ assert.ok(worktreeMatch, promptText);
2535
+ assert.match(worktreeMatch?.[1] ?? "", /^custom-prefix-/);
2536
+ } finally {
2537
+ process.chdir(previousCwd);
2538
+ fs.rmSync(projectBase, { recursive: true, force: true });
2539
+ }
2540
+ });
2541
+
2542
+ test("worktree-backed prompt failures skip merge, restore base-path, and retain worktree", async () => {
2543
+ const { projectBase } = initFixtureRepo({ fixtures: [] });
2544
+ const previousCwd = process.cwd();
2545
+ try {
2546
+ const pi = createFakePi();
2547
+ piUsereqExtension(pi);
2548
+ const ctx = createFakeCtx(projectBase);
2549
+ await pi.emit("session_start", { reason: "startup" }, ctx);
2550
+
2551
+ await pi.commands.get("req-change")!.handler("Adjust docs", ctx);
2552
+ const promptText = String(pi.sentUserMessages[0]?.content ?? "");
2553
+ const worktreeMatch = promptText.match(/created worktree-dir `([^`]+)` and prepared context-path `([^`]+)`\./);
2554
+ assert.ok(worktreeMatch, promptText);
2555
+ const worktreeName = worktreeMatch?.[1] ?? "";
2556
+ const executionBasePath = worktreeMatch?.[2] ?? "";
2557
+
2558
+ await pi.emit("before_agent_start", {}, ctx);
2559
+ assert.equal(process.cwd(), executionBasePath);
2560
+ await pi.emit("agent_start", {}, ctx);
2561
+ fs.mkdirSync(path.join(executionBasePath, "src"), { recursive: true });
2562
+ fs.writeFileSync(path.join(executionBasePath, "src", "failed.ts"), "export const FAILED = 1;\n", "utf8");
2563
+ await pi.emit("agent_end", {
2564
+ messages: [{ role: "assistant", stopReason: "error", content: [] }],
2565
+ }, ctx);
2566
+
2567
+ assert.equal(process.cwd(), projectBase);
2568
+ assert.equal(ctx.cwd, projectBase);
2569
+ assert.equal(fs.existsSync(executionBasePath), true);
2570
+ assert.equal(fs.existsSync(path.join(projectBase, "src", "failed.ts")), false);
2571
+ assert.equal(
2572
+ spawnSync("git", ["show-ref", "--verify", "--quiet", `refs/heads/${worktreeName}`], {
2573
+ cwd: projectBase,
2574
+ encoding: "utf8",
2575
+ }).status,
2576
+ 0,
2577
+ );
2578
+ assert.ok(ctx.__state.notifications.some((entry) => entry.level === "error"
2579
+ && /Prompt closure retained worktree .* after failed outcome\./.test(entry.message)));
2580
+ assert.equal(
2581
+ ctx.__state.statuses.get("pi-usereq"),
2582
+ buildExpectedFakeStatusText({
2583
+ workflowState: "idle",
2584
+ basePath: buildExpectedFakeBasePath(projectBase),
2585
+ docsDir: DEFAULT_DOCS_DIR,
2586
+ testsDir: "tests",
2587
+ srcDir: ["src"],
2588
+ contextFilledCells: 0,
2589
+ et: "⏱︎ --:-- ⚑ 0:00 ⌛︎0:00",
2590
+ }),
2591
+ );
2592
+ } finally {
2593
+ process.chdir(previousCwd);
2594
+ fs.rmSync(projectBase, { recursive: true, force: true });
2595
+ }
2596
+ });
2597
+
2598
+ test("worktree-backed prompt commands use replacement-session callbacks for prompt dispatch and restore base-path after merge", async () => {
2599
+ const { projectBase } = initFixtureRepo({ fixtures: [] });
2600
+ const previousCwd = process.cwd();
2601
+ try {
2602
+ const pi = createFakePi();
2603
+ piUsereqExtension(pi);
2604
+ const ctx = createFakeCtx(projectBase);
2605
+ await pi.emit("session_start", { reason: "startup" }, ctx);
2606
+
2607
+ const originalSessionFile = ctx.sessionManager.getSessionFile();
2608
+ const originalSwitchSession = ctx.switchSession.bind(ctx);
2609
+ let activeSessionCtx: any = ctx;
2610
+ ctx.switchSession = async (
2611
+ sessionPath: string,
2612
+ options?: { withSession?: (replacementCtx: any) => Promise<void> },
2613
+ ) => {
2614
+ const result = await originalSwitchSession(sessionPath);
2615
+ const replacementCwd = readFakeSessionFileCwd(sessionPath, projectBase);
2616
+ const replacementCtx = {
2617
+ ...ctx,
2618
+ cwd: replacementCwd,
2619
+ sessionManager: {
2620
+ ...ctx.sessionManager,
2621
+ getCwd: () => replacementCwd,
2622
+ getSessionFile: () => sessionPath,
2623
+ },
2624
+ async sendUserMessage(content: unknown, sendOptions?: unknown) {
2625
+ pi.sentUserMessages.push({ content, options: sendOptions });
2626
+ },
2627
+ };
2628
+ activeSessionCtx = replacementCtx;
2629
+ if (options?.withSession) {
2630
+ process.chdir(replacementCwd);
2631
+ await options.withSession(replacementCtx);
2632
+ ctx.cwd = projectBase;
2633
+ ctx.sessionManager.getSessionFile = () => originalSessionFile;
2634
+ ctx.sessionManager.getCwd = () => projectBase;
2635
+ return result;
2636
+ }
2637
+ ctx.cwd = projectBase;
2638
+ ctx.sessionManager.getSessionFile = () => originalSessionFile;
2639
+ ctx.sessionManager.getCwd = () => projectBase;
2640
+ process.chdir(projectBase);
2641
+ return result;
2642
+ };
2643
+
2644
+ await pi.commands.get("req-change")!.handler("Adjust docs", ctx);
2645
+
2646
+ assert.equal(pi.sentUserMessages.length, 1);
2647
+ const promptText = String(pi.sentUserMessages[0]?.content ?? "");
2648
+ const worktreeMatch = promptText.match(/created worktree-dir `([^`]+)` and prepared context-path `([^`]+)`\./);
2649
+ assert.ok(worktreeMatch, promptText);
2650
+ const worktreeName = worktreeMatch?.[1] ?? "";
2651
+ const executionBasePath = worktreeMatch?.[2] ?? "";
2652
+ assert.equal(activeSessionCtx.cwd, executionBasePath);
2653
+ assert.equal(process.cwd(), executionBasePath);
2654
+ assert.equal(fs.existsSync(executionBasePath), true);
2655
+
2656
+ await pi.emit("before_agent_start", {}, activeSessionCtx);
2657
+ await pi.emit("agent_start", {}, activeSessionCtx);
2658
+
2659
+ fs.mkdirSync(path.join(executionBasePath, "src"), { recursive: true });
2660
+ fs.writeFileSync(path.join(executionBasePath, "src", "with-session.ts"), "export const WITH_SESSION = 1;\n", "utf8");
2661
+ assert.equal(spawnSync("git", ["add", "src/with-session.ts"], { cwd: executionBasePath, encoding: "utf8" }).status, 0);
2662
+ const worktreeCommit = spawnSync("git", ["commit", "-m", "with session"], {
2663
+ cwd: executionBasePath,
2664
+ encoding: "utf8",
2665
+ });
2666
+ assert.equal(worktreeCommit.status, 0, worktreeCommit.stderr);
2667
+
2668
+ await pi.emit("agent_end", {
2669
+ messages: [{ role: "assistant", stopReason: "stop", content: [] }],
2670
+ }, activeSessionCtx);
2671
+
2672
+ assert.equal(process.cwd(), projectBase);
2673
+ assert.equal(activeSessionCtx.cwd, projectBase);
2674
+ assert.equal(fs.existsSync(executionBasePath), false);
2675
+ assert.equal(fs.existsSync(path.join(projectBase, "src", "with-session.ts")), true);
2676
+ assert.notEqual(
2677
+ spawnSync("git", ["show-ref", "--verify", "--quiet", `refs/heads/${worktreeName}`], {
2678
+ cwd: projectBase,
2679
+ encoding: "utf8",
2680
+ }).status,
2681
+ 0,
2682
+ );
2683
+ } finally {
2684
+ process.chdir(previousCwd);
2685
+ fs.rmSync(projectBase, { recursive: true, force: true });
2686
+ }
2687
+ });
2688
+
2689
+ /**
2690
+ * @brief Verifies worktree-backed prompt orchestration against the pi runtime session-switch contract.
2691
+ * @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.
2692
+ * @return {Promise<void>} Promise resolved after prompt delivery, merge handling, and restored-base assertions complete.
2693
+ * @throws {AssertionError} Throws when prompt activation or prompt-end closure fails to synchronize `process.cwd()` with the expected session path.
2694
+ * @satisfies REQ-068, REQ-208, REQ-257, REQ-271, REQ-272
2695
+ */
2696
+ test("worktree-backed prompt commands restore base-path when switchSession does not change process cwd", async () => {
2697
+ const { projectBase } = initFixtureRepo({ fixtures: [] });
2698
+ const previousCwd = process.cwd();
2699
+ try {
2700
+ process.chdir(projectBase);
2701
+ const pi = createFakePi();
2702
+ piUsereqExtension(pi);
2703
+ const ctx = createFakeCtx(projectBase);
2704
+ await pi.emit("session_start", { reason: "startup" }, ctx);
2705
+
2706
+ let activeSessionCtx: any = ctx;
2707
+ ctx.switchSession = async (
2708
+ sessionPath: string,
2709
+ options?: { withSession?: (replacementCtx: any) => Promise<void> },
2710
+ ) => {
2711
+ const replacementCwd = readFakeSessionFileCwd(sessionPath, projectBase);
2712
+ const replacementCtx = {
2713
+ ...ctx,
2714
+ cwd: replacementCwd,
2715
+ sessionManager: {
2716
+ ...ctx.sessionManager,
2717
+ getCwd: () => replacementCwd,
2718
+ getSessionFile: () => sessionPath,
2719
+ },
2720
+ switchSession: ctx.switchSession,
2721
+ async sendUserMessage(content: unknown, sendOptions?: unknown) {
2722
+ pi.sentUserMessages.push({ content, options: sendOptions });
2723
+ },
2724
+ };
2725
+ activeSessionCtx = replacementCtx;
2726
+ await options?.withSession?.(replacementCtx);
2727
+ return { cancelled: false };
2728
+ };
2729
+
2730
+ await pi.commands.get("req-change")!.handler("Adjust docs", ctx);
2731
+
2732
+ assert.equal(pi.sentUserMessages.length, 1);
2733
+ const promptText = String(pi.sentUserMessages[0]?.content ?? "");
2734
+ const worktreeMatch = promptText.match(/created worktree-dir `([^`]+)` and prepared context-path `([^`]+)`\./);
2735
+ assert.ok(worktreeMatch, promptText);
2736
+ const worktreeName = worktreeMatch?.[1] ?? "";
2737
+ const executionBasePath = worktreeMatch?.[2] ?? "";
2738
+ assert.equal(activeSessionCtx.cwd, executionBasePath);
2739
+ assert.equal(activeSessionCtx.sessionManager.getCwd(), executionBasePath);
2740
+ assert.equal(ctx.cwd, projectBase);
2741
+ assert.equal(process.cwd(), executionBasePath);
2742
+
2743
+ await pi.emit("before_agent_start", {}, activeSessionCtx);
2744
+ await pi.emit("agent_start", {}, activeSessionCtx);
2745
+
2746
+ fs.mkdirSync(path.join(executionBasePath, "src"), { recursive: true });
2747
+ fs.writeFileSync(path.join(executionBasePath, "src", "runtime-anchor.ts"), "export const RUNTIME_ANCHOR = 1;\n", "utf8");
2748
+ assert.equal(
2749
+ spawnSync("git", ["add", "src/runtime-anchor.ts"], {
2750
+ cwd: executionBasePath,
2751
+ encoding: "utf8",
2752
+ }).status,
2753
+ 0,
2754
+ );
2755
+ const worktreeCommit = spawnSync("git", ["commit", "-m", "runtime anchor"], {
2756
+ cwd: executionBasePath,
2757
+ encoding: "utf8",
2758
+ });
2759
+ assert.equal(worktreeCommit.status, 0, worktreeCommit.stderr);
2760
+
2761
+ await pi.emit("agent_end", {
2762
+ messages: [{ role: "assistant", stopReason: "stop", content: [] }],
2763
+ }, activeSessionCtx);
2764
+
2765
+ assert.equal(process.cwd(), projectBase);
2766
+ assert.equal(activeSessionCtx.cwd, projectBase);
2767
+ assert.equal(fs.existsSync(executionBasePath), false);
2768
+ assert.equal(fs.existsSync(path.join(projectBase, "src", "runtime-anchor.ts")), true);
2769
+ assert.notEqual(
2770
+ spawnSync("git", ["show-ref", "--verify", "--quiet", `refs/heads/${worktreeName}`], {
2771
+ cwd: projectBase,
2772
+ encoding: "utf8",
2773
+ }).status,
2774
+ 0,
2775
+ );
2776
+ } finally {
2777
+ process.chdir(previousCwd);
2778
+ fs.rmSync(projectBase, { recursive: true, force: true });
2779
+ }
2780
+ });
2781
+
2782
+ /**
2783
+ * @brief Verifies restored base-path closure tolerates stale `ctx.cwd` mutations after a successful session switch.
2784
+ * @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.
2785
+ * @return {Promise<void>} Promise resolved after restored-base assertions and cleanup complete.
2786
+ * @throws {AssertionError} Throws when the stale post-restore `cwd` mutation still aborts restoration.
2787
+ * @satisfies REQ-208, REQ-257, REQ-276, REQ-280, REQ-282
2788
+ */
2789
+ test("restorePromptCommandExecution ignores stale ctx.cwd mutations after a verified restore switch", async () => {
2790
+ const { projectBase } = initFixtureRepo({ fixtures: [] });
2791
+ const previousCwd = process.cwd();
2792
+ let executionPlan: ReturnType<typeof preparePromptCommandExecution> | undefined;
2793
+ try {
2794
+ const config = loadConfig(projectBase);
2795
+ const ctx = createFakeCtx(projectBase);
2796
+ executionPlan = preparePromptCommandExecution(
2797
+ "change",
2798
+ "Adjust docs",
2799
+ projectBase,
2800
+ config,
2801
+ ctx.sessionManager.getSessionFile(),
2802
+ ctx.sessionManager.getSessionDir?.(),
2803
+ ctx.sessionManager.getBranch?.(),
2804
+ );
2805
+ const activeContext = await activatePromptCommandExecution(executionPlan, ctx);
2806
+ assert.ok(activeContext);
2807
+ let activeContextCwd = activeContext?.cwd;
2808
+ let staleAfterSwitch = false;
2809
+ Object.defineProperty(activeContext!, "cwd", {
2810
+ configurable: true,
2811
+ get() {
2812
+ return activeContextCwd;
2813
+ },
2814
+ set(value: string) {
2815
+ if (staleAfterSwitch) {
2816
+ throw new Error("This extension ctx is stale after session replacement or reload.");
2817
+ }
2818
+ activeContextCwd = value;
2819
+ },
2820
+ });
2821
+ activeContext!.switchSession = async (sessionPath: string) => {
2822
+ activeContextCwd = projectBase;
2823
+ activeContext!.sessionManager.getSessionFile = () => sessionPath;
2824
+ activeContext!.sessionManager.getSessionDir = () => path.dirname(sessionPath);
2825
+ activeContext!.sessionManager.getCwd = () => projectBase;
2826
+ process.chdir(projectBase);
2827
+ staleAfterSwitch = true;
2828
+ return { cancelled: false };
2829
+ };
2830
+
2831
+ const restoredContext = await restorePromptCommandExecution(executionPlan, activeContext, {
2832
+ config,
2833
+ workflowState: "merging",
2834
+ });
2835
+
2836
+ assert.equal(process.cwd(), projectBase);
2837
+ assert.equal(restoredContext, activeContext);
2838
+ assert.equal(fs.existsSync(executionPlan.worktreePath ?? ""), true);
2839
+ } finally {
2840
+ if (executionPlan) {
2841
+ await abortPromptCommandExecution(executionPlan);
2842
+ }
2843
+ process.chdir(previousCwd);
2844
+ fs.rmSync(projectBase, { recursive: true, force: true });
2845
+ }
2846
+ });
2847
+
2848
+ /**
2849
+ * @brief Verifies restored base-path closure tolerates getter-only `ctx.cwd` mirrors after a successful session switch.
2850
+ * @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.
2851
+ * @return {Promise<void>} Promise resolved after restored-base assertions and cleanup complete.
2852
+ * @throws {AssertionError} Throws when a getter-only `cwd` property still aborts restoration.
2853
+ * @satisfies REQ-208, REQ-257, REQ-276, REQ-282
2854
+ */
2855
+ test("restorePromptCommandExecution ignores getter-only ctx.cwd mirrors after a verified restore switch", async () => {
2856
+ const { projectBase } = initFixtureRepo({ fixtures: [] });
2857
+ const previousCwd = process.cwd();
2858
+ let executionPlan: ReturnType<typeof preparePromptCommandExecution> | undefined;
2859
+ try {
2860
+ const config = loadConfig(projectBase);
2861
+ const ctx = createFakeCtx(projectBase);
2862
+ executionPlan = preparePromptCommandExecution(
2863
+ "change",
2864
+ "Adjust docs",
2865
+ projectBase,
2866
+ config,
2867
+ ctx.sessionManager.getSessionFile(),
2868
+ ctx.sessionManager.getSessionDir?.(),
2869
+ ctx.sessionManager.getBranch?.(),
2870
+ );
2871
+ const activeContext = await activatePromptCommandExecution(executionPlan, ctx);
2872
+ assert.ok(activeContext);
2873
+ let activeContextCwd = activeContext?.cwd;
2874
+ Object.defineProperty(activeContext!, "cwd", {
2875
+ configurable: true,
2876
+ get() {
2877
+ return activeContextCwd;
2878
+ },
2879
+ });
2880
+ activeContext!.switchSession = async (sessionPath: string) => {
2881
+ activeContextCwd = projectBase;
2882
+ activeContext!.sessionManager.getSessionFile = () => sessionPath;
2883
+ activeContext!.sessionManager.getSessionDir = () => path.dirname(sessionPath);
2884
+ activeContext!.sessionManager.getCwd = () => projectBase;
2885
+ process.chdir(projectBase);
2886
+ return { cancelled: false };
2887
+ };
2888
+
2889
+ const restoredContext = await restorePromptCommandExecution(executionPlan, activeContext, {
2890
+ config,
2891
+ workflowState: "merging",
2892
+ });
2893
+
2894
+ assert.equal(process.cwd(), projectBase);
2895
+ assert.equal(restoredContext, activeContext);
2896
+ assert.equal(fs.existsSync(executionPlan.worktreePath ?? ""), true);
2897
+ } finally {
2898
+ if (executionPlan) {
2899
+ await abortPromptCommandExecution(executionPlan);
2900
+ }
2901
+ process.chdir(previousCwd);
2902
+ fs.rmSync(projectBase, { recursive: true, force: true });
2903
+ }
2904
+ });
2905
+
2906
+ /**
2907
+ * @brief Verifies worktree-backed closure succeeds when lifecycle event contexts omit `switchSession()`.
2908
+ * @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.
2909
+ * @return {Promise<void>} Promise resolved after closure assertions complete.
2910
+ * @throws {AssertionError} Throws when closure fails to restore `base-path`, merge the worktree, or remove worktree resources.
2911
+ * @satisfies REQ-208, REQ-209, REQ-257, REQ-272, REQ-276, TST-089
2912
+ */
2913
+ test("worktree-backed closure succeeds when agent_end lifecycle contexts omit switchSession", async () => {
2914
+ const { projectBase } = initFixtureRepo({ fixtures: [] });
2915
+ const previousCwd = process.cwd();
2916
+ try {
2917
+ const pi = createFakePi();
2918
+ piUsereqExtension(pi);
2919
+ const commandCtx = createFakeCtx(projectBase);
2920
+ await pi.emit("session_start", { reason: "startup" }, commandCtx);
2921
+
2922
+ await pi.commands.get("req-change")!.handler("Adjust docs", commandCtx);
2923
+ const promptText = String(pi.sentUserMessages[0]?.content ?? "");
2924
+ const worktreeMatch = promptText.match(/created worktree-dir `([^`]+)` and prepared context-path `([^`]+)`\./);
2925
+ assert.ok(worktreeMatch, promptText);
2926
+ const worktreeName = worktreeMatch?.[1] ?? "";
2927
+ const executionBasePath = worktreeMatch?.[2] ?? "";
2928
+ const executionSessionFile = commandCtx.sessionManager.getSessionFile() ?? "";
2929
+ assert.notEqual(executionSessionFile, "");
2930
+
2931
+ const eventCtx = createFakeCtx(executionBasePath);
2932
+ eventCtx.sessionManager.getSessionFile = () => executionSessionFile;
2933
+ eventCtx.sessionManager.getSessionDir = () => path.dirname(executionSessionFile);
2934
+ eventCtx.sessionManager.getCwd = () => executionBasePath;
2935
+ eventCtx.cwd = executionBasePath;
2936
+ delete eventCtx.switchSession;
2937
+
2938
+ await pi.emit("before_agent_start", {}, eventCtx);
2939
+ await pi.emit("agent_start", {}, eventCtx);
2940
+ fs.mkdirSync(path.join(executionBasePath, "src"), { recursive: true });
2941
+ fs.writeFileSync(path.join(executionBasePath, "src", "closure-context.ts"), "export const CLOSURE_CONTEXT = 1;\n", "utf8");
2942
+ assert.equal(spawnSync("git", ["add", "src/closure-context.ts"], { cwd: executionBasePath, encoding: "utf8" }).status, 0);
2943
+ const worktreeCommit = spawnSync("git", ["commit", "-m", "closure context"], {
2944
+ cwd: executionBasePath,
2945
+ encoding: "utf8",
2946
+ });
2947
+ assert.equal(worktreeCommit.status, 0, worktreeCommit.stderr);
2948
+
2949
+ await pi.emit("agent_end", {
2950
+ messages: [{ role: "assistant", stopReason: "stop", content: [] }],
2951
+ }, eventCtx);
2952
+
2953
+ assert.equal(process.cwd(), projectBase);
2954
+ assert.equal(commandCtx.cwd, projectBase);
2955
+ assert.ok(fs.existsSync(path.join(projectBase, "src", "closure-context.ts")));
2956
+ assert.equal(fs.existsSync(executionBasePath), false);
2957
+ assert.notEqual(
2958
+ spawnSync("git", ["show-ref", "--verify", "--quiet", `refs/heads/${worktreeName}`], {
2959
+ cwd: projectBase,
2960
+ encoding: "utf8",
2961
+ }).status,
2962
+ 0,
2963
+ );
2964
+ assert.equal(commandCtx.__state.notifications.filter((entry) => entry.level === "error").length, 0);
2965
+ } finally {
2966
+ process.chdir(previousCwd);
2967
+ fs.rmSync(projectBase, { recursive: true, force: true });
2968
+ }
2969
+ });
2970
+
2971
+ /**
2972
+ * @brief Verifies switch-triggered `session_shutdown` preserves prompt-command state for same-runtime workflow closure.
2973
+ * @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.
2974
+ * @return {Promise<void>} Promise resolved after session-shutdown preservation and closure assertions complete.
2975
+ * @throws {AssertionError} Throws when switch-triggered shutdown clears prompt state, logs the wrong workflow state, or prevents merge and cleanup.
2976
+ * @satisfies REQ-221, REQ-227, REQ-278, TST-090
2977
+ */
2978
+ test("switch-triggered session_shutdown preserves prompt state for same-runtime workflow closure", async () => {
2979
+ const { projectBase } = initFixtureRepo({ fixtures: [] });
2980
+ const previousCwd = process.cwd();
2981
+ try {
2982
+ writeProjectConfigOverrides(projectBase, {
2983
+ DEBUG_ENABLED: "enable",
2984
+ DEBUG_LOG_FILE: "prompt-debug.json",
2985
+ DEBUG_STATUS_CHANGES: "enable",
2986
+ DEBUG_WORKFLOW_EVENTS: "enable",
2987
+ DEBUG_LOG_ON_STATUS: "any",
2988
+ DEBUG_ENABLED_PROMPTS: ["req-change"],
2989
+ });
2990
+ const pi = createFakePi();
2991
+ piUsereqExtension(pi);
2992
+ const ctx = createFakeCtx(projectBase);
2993
+ await pi.emit("session_start", { reason: "startup" }, ctx);
2994
+
2995
+ const originalSwitchSession = ctx.switchSession.bind(ctx);
2996
+ ctx.switchSession = async (sessionPath: string) => {
2997
+ await pi.emit("session_shutdown", {
2998
+ reason: "resume",
2999
+ targetSessionFile: sessionPath,
3000
+ }, ctx);
3001
+ return originalSwitchSession(sessionPath);
3002
+ };
3003
+
3004
+ await pi.commands.get("req-change")!.handler("Adjust docs", ctx);
3005
+ const promptText = String(pi.sentUserMessages[0]?.content ?? "");
3006
+ const worktreeMatch = promptText.match(/created worktree-dir `([^`]+)` and prepared context-path `([^`]+)`\./);
3007
+ assert.ok(worktreeMatch, promptText);
3008
+ const executionBasePath = worktreeMatch?.[2] ?? "";
3009
+
3010
+ const activationLog = JSON.parse(fs.readFileSync(path.join(projectBase, "prompt-debug.json"), "utf8"));
3011
+ const shutdownEntry = activationLog.find((entry: any) => entry.action === "workflow_session_shutdown");
3012
+ assert.equal(shutdownEntry?.workflow_state, "checking");
3013
+ assert.equal(shutdownEntry?.result?.preserve_prompt_command_state, true);
3014
+ assert.equal(shutdownEntry?.result?.pending_prompt_request, true);
3015
+ assert.equal(shutdownEntry?.result?.active_prompt_request, false);
3016
+ assert.ok(activationLog.some((entry: any) => entry.action === "workflow_activation" && entry.workflow_state === "checking"));
3017
+ assert.ok(
3018
+ activationLog.some(
3019
+ (entry: any) => entry.action === "workflow_state"
3020
+ && entry.input?.from_state === "checking"
3021
+ && entry.result?.workflow_state === "running",
3022
+ ),
3023
+ );
3024
+
3025
+ await pi.emit("before_agent_start", {}, ctx);
3026
+ await pi.emit("agent_start", {}, ctx);
3027
+ fs.mkdirSync(path.join(executionBasePath, "src"), { recursive: true });
3028
+ fs.writeFileSync(path.join(executionBasePath, "src", "shutdown-preserve.ts"), "export const SHUTDOWN_PRESERVE = 1;\n", "utf8");
3029
+ assert.equal(spawnSync("git", ["add", "src/shutdown-preserve.ts"], { cwd: executionBasePath, encoding: "utf8" }).status, 0);
3030
+ const worktreeCommit = spawnSync("git", ["commit", "-m", "shutdown preserve"], {
3031
+ cwd: executionBasePath,
3032
+ encoding: "utf8",
3033
+ });
3034
+ assert.equal(worktreeCommit.status, 0, worktreeCommit.stderr);
3035
+
3036
+ await pi.emit("agent_end", {
3037
+ messages: [{ role: "assistant", stopReason: "stop", content: [] }],
3038
+ }, ctx);
3039
+
3040
+ assert.equal(process.cwd(), projectBase);
3041
+ assert.equal(ctx.cwd, projectBase);
3042
+ assert.ok(fs.existsSync(path.join(projectBase, "src", "shutdown-preserve.ts")));
3043
+ assert.equal(fs.existsSync(executionBasePath), false);
3044
+ assert.equal(ctx.__state.notifications.filter((entry) => entry.level === "error").length, 0);
3045
+ } finally {
3046
+ process.chdir(previousCwd);
3047
+ fs.rmSync(projectBase, { recursive: true, force: true });
3048
+ }
3049
+ });
3050
+
3051
+ /**
3052
+ * @brief Verifies replacement-session runtimes resynchronize persisted prompt state after switch-triggered rebinding.
3053
+ * @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.
3054
+ * @return {Promise<void>} Promise resolved after rebound-session closure assertions complete.
3055
+ * @throws {AssertionError} Throws when the replacement-session runtime misses the persisted `running` state and skips merge plus cleanup.
3056
+ * @satisfies REQ-227, REQ-279, TST-091
3057
+ */
3058
+ test("replacement-session runtime resynchronizes persisted running state before prompt closure", async () => {
3059
+ const { projectBase } = initFixtureRepo({ fixtures: [] });
3060
+ const previousCwd = process.cwd();
3061
+ try {
3062
+ writeProjectConfigOverrides(projectBase, {
3063
+ DEBUG_ENABLED: "enable",
3064
+ DEBUG_LOG_FILE: "prompt-debug.json",
3065
+ DEBUG_STATUS_CHANGES: "enable",
3066
+ DEBUG_WORKFLOW_EVENTS: "enable",
3067
+ DEBUG_LOG_ON_STATUS: "any",
3068
+ DEBUG_ENABLED_PROMPTS: ["req-change"],
3069
+ });
3070
+ const firstPi = createFakePi();
3071
+ piUsereqExtension(firstPi);
3072
+ const firstCtx = createFakeCtx(projectBase);
3073
+ await firstPi.emit("session_start", { reason: "startup" }, firstCtx);
3074
+
3075
+ const originalSessionFile = firstCtx.sessionManager.getSessionFile() ?? "";
3076
+ const originalSwitchSession = firstCtx.switchSession.bind(firstCtx);
3077
+ let reboundPi: FakePi | undefined;
3078
+ let reboundCtx: any;
3079
+ firstCtx.switchSession = async (sessionPath: string) => {
3080
+ const executionSessionCwd = readFakeSessionFileCwd(sessionPath, firstCtx.cwd);
3081
+ await firstPi.emit("session_shutdown", {
3082
+ reason: "resume",
3083
+ targetSessionFile: sessionPath,
3084
+ }, firstCtx);
3085
+ if (!reboundPi) {
3086
+ reboundPi = createFakePi();
3087
+ piUsereqExtension(reboundPi);
3088
+ reboundCtx = createFakeCtx(executionSessionCwd);
3089
+ reboundCtx.sessionManager.getSessionFile = () => sessionPath;
3090
+ reboundCtx.sessionManager.getSessionDir = () => path.dirname(sessionPath);
3091
+ reboundCtx.sessionManager.getCwd = () => executionSessionCwd;
3092
+ reboundCtx.cwd = executionSessionCwd;
3093
+ await reboundPi.emit("session_start", {
3094
+ reason: "resume",
3095
+ previousSessionFile: originalSessionFile,
3096
+ }, reboundCtx);
3097
+ }
3098
+ return originalSwitchSession(sessionPath);
3099
+ };
3100
+
3101
+ await firstPi.commands.get("req-change")!.handler("Adjust docs", firstCtx);
3102
+ assert.ok(reboundPi);
3103
+ assert.ok(reboundCtx);
3104
+ const promptText = String(firstPi.sentUserMessages[0]?.content ?? "");
3105
+ const worktreeMatch = promptText.match(/created worktree-dir `([^`]+)` and prepared context-path `([^`]+)`\./);
3106
+ assert.ok(worktreeMatch, promptText);
3107
+ const executionBasePath = worktreeMatch?.[2] ?? "";
3108
+
3109
+ await reboundPi!.emit("before_agent_start", {}, reboundCtx);
3110
+ await reboundPi!.emit("agent_start", {}, reboundCtx);
3111
+ fs.mkdirSync(path.join(executionBasePath, "src"), { recursive: true });
3112
+ fs.writeFileSync(path.join(executionBasePath, "src", "rebound-running.ts"), "export const REBOUND_RUNNING = 1;\n", "utf8");
3113
+ assert.equal(spawnSync("git", ["add", "src/rebound-running.ts"], { cwd: executionBasePath, encoding: "utf8" }).status, 0);
3114
+ const reboundCommit = spawnSync("git", ["commit", "-m", "rebound running"], { cwd: executionBasePath, encoding: "utf8" });
3115
+ assert.equal(reboundCommit.status, 0, reboundCommit.stderr);
3116
+
3117
+ await reboundPi!.emit("agent_end", {
3118
+ messages: [{ role: "assistant", stopReason: "stop", content: [] }],
3119
+ }, reboundCtx);
3120
+
3121
+ const debugLog = JSON.parse(fs.readFileSync(path.join(projectBase, "prompt-debug.json"), "utf8"));
3122
+ assert.ok(
3123
+ debugLog.some(
3124
+ (entry: any) => entry.action === "workflow_closure"
3125
+ && entry.result?.should_finalize_matched_success === true,
3126
+ ),
3127
+ );
3128
+ assert.equal(process.cwd(), projectBase);
3129
+ assert.equal(reboundCtx.cwd, projectBase);
3130
+ assert.equal(fs.existsSync(executionBasePath), false);
3131
+ assert.equal(fs.existsSync(path.join(projectBase, "src", "rebound-running.ts")), true);
3132
+ assert.equal(reboundCtx.__state.notifications.filter((entry) => entry.level === "error").length, 0);
3133
+ } finally {
3134
+ process.chdir(previousCwd);
3135
+ fs.rmSync(projectBase, { recursive: true, force: true });
3136
+ }
3137
+ });
3138
+
3139
+ /**
3140
+ * @brief Verifies `running` is persisted before async replacement-session prompt delivery completes.
3141
+ * @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.
3142
+ * @return {Promise<void>} Promise resolved after delayed prompt delivery, rebound closure, and cleanup assertions complete.
3143
+ * @throws {AssertionError} Throws when `running` is delayed until after `agent_end`, causing closure to skip merge or cleanup.
3144
+ * @satisfies REQ-227, REQ-279, REQ-281, TST-093
3145
+ */
3146
+ test("replacement-session prompt delivery persists running before async sendUserMessage resolves", async () => {
3147
+ const { projectBase } = initFixtureRepo({ fixtures: [] });
3148
+ const previousCwd = process.cwd();
3149
+ try {
3150
+ writeProjectConfigOverrides(projectBase, {
3151
+ DEBUG_ENABLED: "enable",
3152
+ DEBUG_LOG_FILE: "prompt-debug.json",
3153
+ DEBUG_STATUS_CHANGES: "enable",
3154
+ DEBUG_WORKFLOW_EVENTS: "enable",
3155
+ DEBUG_LOG_ON_STATUS: "any",
3156
+ DEBUG_ENABLED_PROMPTS: ["req-change"],
3157
+ });
3158
+ const firstPi = createFakePi();
3159
+ piUsereqExtension(firstPi);
3160
+ const firstCtx = createFakeCtx(projectBase);
3161
+ await firstPi.emit("session_start", { reason: "startup" }, firstCtx);
3162
+
3163
+ const originalSessionFile = firstCtx.sessionManager.getSessionFile() ?? "";
3164
+ const originalSwitchSession = firstCtx.switchSession.bind(firstCtx);
3165
+ let reboundPi: FakePi | undefined;
3166
+ let reboundCtx: any;
3167
+ let resolvePromptDelivery: (() => void) | undefined;
3168
+ let handoffStatus = "";
3169
+ firstCtx.switchSession = async (
3170
+ sessionPath: string,
3171
+ options?: { withSession?: (replacementCtx: any) => Promise<void> },
3172
+ ) => {
3173
+ const executionSessionCwd = readFakeSessionFileCwd(sessionPath, firstCtx.cwd);
3174
+ await firstPi.emit("session_shutdown", {
3175
+ reason: "resume",
3176
+ targetSessionFile: sessionPath,
3177
+ }, firstCtx);
3178
+ if (!reboundPi) {
3179
+ reboundPi = createFakePi();
3180
+ piUsereqExtension(reboundPi);
3181
+ reboundCtx = createFakeCtx(executionSessionCwd);
3182
+ reboundCtx.sessionManager.getSessionFile = () => sessionPath;
3183
+ reboundCtx.sessionManager.getSessionDir = () => path.dirname(sessionPath);
3184
+ reboundCtx.sessionManager.getCwd = () => executionSessionCwd;
3185
+ reboundCtx.cwd = executionSessionCwd;
3186
+ await reboundPi.emit("session_start", {
3187
+ reason: "resume",
3188
+ previousSessionFile: originalSessionFile,
3189
+ }, reboundCtx);
3190
+ }
3191
+ const switchResult = await originalSwitchSession(sessionPath);
3192
+ const replacementCtx = {
3193
+ ...firstCtx,
3194
+ cwd: executionSessionCwd,
3195
+ sessionManager: {
3196
+ ...firstCtx.sessionManager,
3197
+ getCwd: () => executionSessionCwd,
3198
+ getSessionFile: () => sessionPath,
3199
+ getSessionDir: () => path.dirname(sessionPath),
3200
+ },
3201
+ switchSession: firstCtx.switchSession,
3202
+ async sendUserMessage(content: unknown, sendOptions?: unknown) {
3203
+ handoffStatus = firstCtx.__state.statuses.get("pi-usereq") ?? "";
3204
+ firstPi.sentUserMessages.push({ content, options: sendOptions });
3205
+ await new Promise<void>((resolve) => {
3206
+ resolvePromptDelivery = resolve;
3207
+ });
3208
+ },
3209
+ };
3210
+ await options?.withSession?.(replacementCtx);
3211
+ return switchResult;
3212
+ };
3213
+
3214
+ const handlerPromise = firstPi.commands.get("req-change")!.handler("Adjust docs", firstCtx);
3215
+ for (let attempt = 0; attempt < 20 && resolvePromptDelivery === undefined; attempt += 1) {
3216
+ await new Promise((resolve) => setTimeout(resolve, 0));
3217
+ }
3218
+ assert.ok(resolvePromptDelivery);
3219
+ assert.ok(reboundPi);
3220
+ assert.ok(reboundCtx);
3221
+ const promptText = String(firstPi.sentUserMessages[0]?.content ?? "");
3222
+ const worktreeMatch = promptText.match(/created worktree-dir `([^`]+)` and prepared context-path `([^`]+)`\./);
3223
+ assert.ok(worktreeMatch, promptText);
3224
+ const executionBasePath = worktreeMatch?.[2] ?? "";
3225
+
3226
+ assert.equal(
3227
+ handoffStatus,
3228
+ buildExpectedFakeStatusText({
3229
+ workflowState: "checking",
3230
+ basePath: buildExpectedFakeBasePath(executionBasePath),
3231
+ docsDir: DEFAULT_DOCS_DIR,
3232
+ testsDir: "tests",
3233
+ srcDir: ["src"],
3234
+ contextFilledCells: 0,
3235
+ et: "⏱︎ --:-- ⚑ --:-- ⌛︎--:--",
3236
+ }),
3237
+ );
3238
+ assert.equal(
3239
+ firstCtx.__state.statuses.get("pi-usereq"),
3240
+ buildExpectedFakeStatusText({
3241
+ workflowState: "running",
3242
+ basePath: buildExpectedFakeBasePath(executionBasePath),
3243
+ docsDir: DEFAULT_DOCS_DIR,
3244
+ testsDir: "tests",
3245
+ srcDir: ["src"],
3246
+ contextFilledCells: 0,
3247
+ et: "⏱︎ --:-- ⚑ --:-- ⌛︎--:--",
3248
+ }),
3249
+ );
3250
+
3251
+ await reboundPi!.emit("before_agent_start", {}, reboundCtx);
3252
+ await reboundPi!.emit("agent_start", {}, reboundCtx);
3253
+ fs.mkdirSync(path.join(executionBasePath, "src"), { recursive: true });
3254
+ fs.writeFileSync(path.join(executionBasePath, "src", "delayed-running.ts"), "export const DELAYED_RUNNING = 1;\n", "utf8");
3255
+ assert.equal(spawnSync("git", ["add", "src/delayed-running.ts"], { cwd: executionBasePath, encoding: "utf8" }).status, 0);
3256
+ const reboundCommit = spawnSync("git", ["commit", "-m", "delayed running"], { cwd: executionBasePath, encoding: "utf8" });
3257
+ assert.equal(reboundCommit.status, 0, reboundCommit.stderr);
3258
+
3259
+ await reboundPi!.emit("agent_end", {
3260
+ messages: [{ role: "assistant", stopReason: "stop", content: [] }],
3261
+ }, reboundCtx);
3262
+
3263
+ const debugLog = JSON.parse(fs.readFileSync(path.join(projectBase, "prompt-debug.json"), "utf8"));
3264
+ assert.ok(
3265
+ debugLog.some(
3266
+ (entry: any) => entry.action === "workflow_closure"
3267
+ && entry.result?.should_finalize_matched_success === true,
3268
+ ),
3269
+ );
3270
+
3271
+ resolvePromptDelivery!();
3272
+ await handlerPromise;
3273
+
3274
+ assert.equal(process.cwd(), projectBase);
3275
+ assert.equal(reboundCtx.cwd, projectBase);
3276
+ assert.equal(fs.existsSync(executionBasePath), false);
3277
+ assert.equal(fs.existsSync(path.join(projectBase, "src", "delayed-running.ts")), true);
3278
+ assert.equal(reboundCtx.__state.notifications.filter((entry) => entry.level === "error").length, 0);
3279
+ } finally {
3280
+ process.chdir(previousCwd);
3281
+ fs.rmSync(projectBase, { recursive: true, force: true });
3282
+ }
3283
+ });
3284
+
3285
+ /**
3286
+ * @brief Verifies late stale replacement-session prompt-delivery rejections do not abort successful worktree closure.
3287
+ * @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.
3288
+ * @return {Promise<void>} Promise resolved after stale delivery rejection, matched-success closure, and cleanup assertions complete.
3289
+ * @throws {AssertionError} Throws when the late stale rejection bubbles out of the command handler or prevents merge plus cleanup.
3290
+ * @satisfies REQ-208, REQ-276, REQ-278, REQ-279, REQ-280, REQ-281, REQ-282
3291
+ */
3292
+ test("late stale replacement-session prompt-delivery rejections do not interrupt worktree closure", async () => {
3293
+ const { projectBase } = initFixtureRepo({ fixtures: [] });
3294
+ const previousCwd = process.cwd();
3295
+ try {
3296
+ writeProjectConfigOverrides(projectBase, {
3297
+ DEBUG_ENABLED: "enable",
3298
+ DEBUG_LOG_FILE: "prompt-debug.json",
3299
+ DEBUG_STATUS_CHANGES: "enable",
3300
+ DEBUG_WORKFLOW_EVENTS: "enable",
3301
+ DEBUG_LOG_ON_STATUS: "any",
3302
+ DEBUG_ENABLED_PROMPTS: ["req-change"],
3303
+ });
3304
+ const firstPi = createFakePi();
3305
+ piUsereqExtension(firstPi);
3306
+ const firstCtx = createFakeCtx(projectBase);
3307
+ await firstPi.emit("session_start", { reason: "startup" }, firstCtx);
3308
+
3309
+ const originalSessionFile = firstCtx.sessionManager.getSessionFile() ?? "";
3310
+ const originalSwitchSession = firstCtx.switchSession.bind(firstCtx);
3311
+ let reboundPi: FakePi | undefined;
3312
+ let reboundCtx: any;
3313
+ let rejectPromptDelivery: ((error: unknown) => void) | undefined;
3314
+ firstCtx.switchSession = async (
3315
+ sessionPath: string,
3316
+ options?: { withSession?: (replacementCtx: any) => Promise<void> },
3317
+ ) => {
3318
+ const replacementCwd = readFakeSessionFileCwd(sessionPath, firstCtx.cwd);
3319
+ await firstPi.emit("session_shutdown", {
3320
+ reason: "resume",
3321
+ targetSessionFile: sessionPath,
3322
+ }, firstCtx);
3323
+ if (!reboundPi) {
3324
+ reboundPi = createFakePi();
3325
+ piUsereqExtension(reboundPi);
3326
+ reboundCtx = createFakeCtx(replacementCwd);
3327
+ reboundCtx.sessionManager.getSessionFile = () => sessionPath;
3328
+ reboundCtx.sessionManager.getSessionDir = () => path.dirname(sessionPath);
3329
+ reboundCtx.sessionManager.getCwd = () => replacementCwd;
3330
+ reboundCtx.cwd = replacementCwd;
3331
+ await reboundPi.emit("session_start", {
3332
+ reason: "resume",
3333
+ previousSessionFile: originalSessionFile,
3334
+ }, reboundCtx);
3335
+ }
3336
+ const switchResult = await originalSwitchSession(sessionPath);
3337
+ const replacementCtx = {
3338
+ ...firstCtx,
3339
+ cwd: replacementCwd,
3340
+ sessionManager: {
3341
+ ...firstCtx.sessionManager,
3342
+ getCwd: () => replacementCwd,
3343
+ getSessionFile: () => sessionPath,
3344
+ getSessionDir: () => path.dirname(sessionPath),
3345
+ },
3346
+ switchSession: firstCtx.switchSession,
3347
+ async sendUserMessage(content: unknown, sendOptions?: unknown) {
3348
+ firstPi.sentUserMessages.push({ content, options: sendOptions });
3349
+ await new Promise<void>((_resolve, reject) => {
3350
+ rejectPromptDelivery = reject;
3351
+ });
3352
+ },
3353
+ };
3354
+ if (path.resolve(sessionPath) === path.resolve(originalSessionFile)) {
3355
+ rejectPromptDelivery?.(
3356
+ new Error("This extension ctx is stale after session replacement or reload."),
3357
+ );
3358
+ rejectPromptDelivery = undefined;
3359
+ }
3360
+ await options?.withSession?.(replacementCtx);
3361
+ return switchResult;
3362
+ };
3363
+
3364
+ const handlerPromise = firstPi.commands.get("req-change")!.handler("Adjust docs", firstCtx);
3365
+ const handlerOutcomePromise = handlerPromise.then(
3366
+ () => ({ error: undefined as unknown }),
3367
+ (error) => ({ error }),
3368
+ );
3369
+ for (let attempt = 0; attempt < 20 && rejectPromptDelivery === undefined; attempt += 1) {
3370
+ await new Promise((resolve) => setTimeout(resolve, 0));
3371
+ }
3372
+ assert.ok(rejectPromptDelivery);
3373
+ assert.ok(reboundPi);
3374
+ assert.ok(reboundCtx);
3375
+ const promptText = String(firstPi.sentUserMessages[0]?.content ?? "");
3376
+ const worktreeMatch = promptText.match(/created worktree-dir `([^`]+)` and prepared context-path `([^`]+)`\./);
3377
+ assert.ok(worktreeMatch, promptText);
3378
+ const worktreeName = worktreeMatch?.[1] ?? "";
3379
+ const executionBasePath = worktreeMatch?.[2] ?? "";
3380
+ const executionSessionFile = firstCtx.sessionManager.getSessionFile() ?? "";
3381
+ assert.notEqual(executionSessionFile, "");
3382
+
3383
+ await reboundPi!.emit("before_agent_start", {}, reboundCtx);
3384
+ await reboundPi!.emit("agent_start", {}, reboundCtx);
3385
+
3386
+ const persistedContext = readPersistedPromptCommandSessionContext(executionSessionFile);
3387
+ assert.ok(persistedContext);
3388
+ reboundCtx.switchSession = async (sessionPath: string) => {
3389
+ assert.equal(path.resolve(sessionPath), path.resolve(originalSessionFile));
3390
+ const pendingPromptDeliveryRejection = rejectPromptDelivery;
3391
+ rejectPromptDelivery = undefined;
3392
+ queueMicrotask(() => {
3393
+ pendingPromptDeliveryRejection?.(
3394
+ new Error("This extension ctx is stale after session replacement or reload."),
3395
+ );
3396
+ });
3397
+ reboundCtx.cwd = projectBase;
3398
+ reboundCtx.sessionManager.getSessionFile = () => originalSessionFile;
3399
+ reboundCtx.sessionManager.getSessionDir = () => path.dirname(originalSessionFile);
3400
+ reboundCtx.sessionManager.getCwd = () => projectBase;
3401
+ process.chdir(projectBase);
3402
+ return { cancelled: false };
3403
+ };
3404
+
3405
+ await Promise.race([
3406
+ reboundPi!.emit("agent_end", {
3407
+ messages: [{ role: "assistant", stopReason: "stop", content: [] }],
3408
+ }, reboundCtx),
3409
+ new Promise<never>((_resolve, reject) => {
3410
+ setTimeout(() => reject(new Error("agent_end timed out")), 1_000);
3411
+ }),
3412
+ ]);
3413
+ assert.equal(rejectPromptDelivery, undefined);
3414
+ const handlerOutcome = await Promise.race([
3415
+ handlerOutcomePromise,
3416
+ new Promise<never>((_resolve, reject) => {
3417
+ setTimeout(() => reject(new Error("handler timed out")), 1_000);
3418
+ }),
3419
+ ]);
3420
+ assert.equal(handlerOutcome.error, undefined);
3421
+
3422
+ const debugLog = JSON.parse(fs.readFileSync(path.join(projectBase, "prompt-debug.json"), "utf8"));
3423
+ assert.ok(debugLog.some((entry: any) => entry.action === "workflow_restore" && entry.result?.success === true));
3424
+ assert.ok(debugLog.some((entry: any) => entry.action === "merge" && entry.result?.success === true));
3425
+ assert.equal(process.cwd(), projectBase);
3426
+ assert.equal(reboundCtx.cwd, projectBase);
3427
+ assert.equal(fs.existsSync(executionBasePath), false);
3428
+ assert.notEqual(
3429
+ spawnSync("git", ["show-ref", "--verify", "--quiet", `refs/heads/${worktreeName}`], {
3430
+ cwd: projectBase,
3431
+ encoding: "utf8",
3432
+ }).status,
3433
+ 0,
3434
+ );
3435
+ assert.equal(reboundCtx.__state.notifications.filter((entry) => entry.level === "error").length, 0);
3436
+ } finally {
3437
+ process.chdir(previousCwd);
3438
+ fs.rmSync(projectBase, { recursive: true, force: true });
3439
+ }
3440
+ });
3441
+
3442
+ test("worktree-backed prompt execution survives switch-triggered extension rebinding", async () => {
3443
+ const { projectBase } = initFixtureRepo({ fixtures: [] });
3444
+ const previousCwd = process.cwd();
3445
+ try {
3446
+ const firstPi = createFakePi();
3447
+ piUsereqExtension(firstPi);
3448
+ const firstCtx = createFakeCtx(projectBase);
3449
+ await firstPi.emit("session_start", { reason: "startup" }, firstCtx);
3450
+
3451
+ await firstPi.commands.get("req-change")!.handler("Adjust docs", firstCtx);
3452
+ const promptText = String(firstPi.sentUserMessages[0]?.content ?? "");
3453
+ const worktreeMatch = promptText.match(/created worktree-dir `([^`]+)` and prepared context-path `([^`]+)`\./);
3454
+ assert.ok(worktreeMatch, promptText);
3455
+ const executionBasePath = worktreeMatch?.[2] ?? "";
3456
+ const executionSessionFile = firstCtx.sessionManager.getSessionFile() ?? "";
3457
+ assert.notEqual(executionSessionFile, "");
3458
+
3459
+ const reboundPi = createFakePi();
3460
+ piUsereqExtension(reboundPi);
3461
+ const reboundCtx = createFakeCtx(executionBasePath);
3462
+ reboundCtx.sessionManager.getSessionFile = () => executionSessionFile;
3463
+ reboundCtx.sessionManager.getSessionDir = () => path.dirname(executionSessionFile);
3464
+ reboundCtx.sessionManager.getCwd = () => executionBasePath;
3465
+ reboundCtx.cwd = executionBasePath;
3466
+ await reboundPi.emit("session_start", {
3467
+ reason: "resume",
3468
+ previousSessionFile: firstCtx.sessionManager.getSessionFile(),
3469
+ }, reboundCtx);
3470
+
3471
+ await reboundPi.emit("before_agent_start", {}, reboundCtx);
3472
+ await reboundPi.emit("agent_start", {}, reboundCtx);
3473
+ fs.mkdirSync(path.join(executionBasePath, "src"), { recursive: true });
3474
+ fs.writeFileSync(path.join(executionBasePath, "src", "rebound.ts"), "export const REBOUND = 1;\n", "utf8");
3475
+ assert.equal(spawnSync("git", ["add", "src/rebound.ts"], { cwd: executionBasePath, encoding: "utf8" }).status, 0);
3476
+ const reboundCommit = spawnSync("git", ["commit", "-m", "rebound"], { cwd: executionBasePath, encoding: "utf8" });
3477
+ assert.equal(reboundCommit.status, 0, reboundCommit.stderr);
3478
+
3479
+ await reboundPi.emit("agent_end", {
3480
+ messages: [{ role: "assistant", stopReason: "stop", content: [] }],
3481
+ }, reboundCtx);
3482
+
3483
+ assert.equal(process.cwd(), projectBase);
3484
+ assert.equal(reboundCtx.cwd, projectBase);
3485
+ assert.equal(fs.existsSync(executionBasePath), false);
3486
+ assert.equal(fs.existsSync(path.join(projectBase, "src", "rebound.ts")), true);
3487
+ assert.equal(reboundCtx.__state.notifications.filter((entry) => entry.level === "error").length, 0);
3488
+ } finally {
3489
+ process.chdir(previousCwd);
3490
+ fs.rmSync(projectBase, { recursive: true, force: true });
3491
+ }
3492
+ });
3493
+
3494
+ /**
3495
+ * @brief Reproduces the defect where `ctx.switchSession(sessionPath)` ignores a `withSession` option and leaves the handler-scoped ctx frozen on the pre-switch session.
3496
+ * @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.
3497
+ * @return {Promise<void>} Promise resolved after prompt delivery, activation, and assertions complete.
3498
+ * @throws {AssertionError} Throws when prompt activation aborts even though the persisted execution-session file and the host `process.cwd()` both match the worktree path.
3499
+ * @satisfies REQ-068, REQ-208, REQ-257, REQ-271, REQ-272
3500
+ */
3501
+ test("worktree-backed prompt commands activate when ctx.switchSession only accepts a single argument and leaves handler ctx stale", async () => {
3502
+ const { projectBase } = initFixtureRepo({ fixtures: [] });
3503
+ const previousCwd = process.cwd();
3504
+ try {
3505
+ process.chdir(projectBase);
3506
+ const pi = createFakePi();
3507
+ piUsereqExtension(pi);
3508
+ const ctx = createFakeCtx(projectBase);
3509
+ await pi.emit("session_start", { reason: "startup" }, ctx);
3510
+
3511
+ const frozenSessionFile = ctx.sessionManager.getSessionFile() ?? "";
3512
+ const frozenSessionCwd = ctx.sessionManager.getCwd();
3513
+ ctx.switchSession = async (sessionPath: string, ..._ignored: unknown[]) => {
3514
+ // Mirror the real pi SDK: accept only sessionPath, never mutate ctx.cwd or ctx.sessionManager.
3515
+ void sessionPath;
3516
+ return { cancelled: false };
3517
+ };
3518
+
3519
+ await pi.commands.get("req-change")!.handler("Adjust docs", ctx);
3520
+
3521
+ assert.equal(pi.sentUserMessages.length, 1);
3522
+ const promptText = String(pi.sentUserMessages[0]?.content ?? "");
3523
+ const worktreeMatch = promptText.match(/created worktree-dir `([^`]+)` and prepared context-path `([^`]+)`\./);
3524
+ assert.ok(worktreeMatch, promptText);
3525
+ const executionBasePath = worktreeMatch?.[2] ?? "";
3526
+ assert.equal(process.cwd(), executionBasePath);
3527
+ assert.equal(ctx.cwd, projectBase);
3528
+ assert.equal(ctx.sessionManager.getSessionFile(), frozenSessionFile);
3529
+ assert.equal(ctx.sessionManager.getCwd(), frozenSessionCwd);
3530
+ assert.equal(
3531
+ ctx.__state.notifications.some((entry) => /Prompt execution activation expected/.test(entry.message)),
3532
+ false,
3533
+ );
3534
+ } finally {
3535
+ process.chdir(previousCwd);
3536
+ fs.rmSync(projectBase, { recursive: true, force: true });
3537
+ }
3538
+ });
3539
+
3540
+ test("worktree-backed prompt commands tolerate stale ctx.sessionManager probes after switchSession returns non-cancelled", async () => {
3541
+ const { projectBase } = initFixtureRepo({ fixtures: [] });
3542
+ const previousCwd = process.cwd();
3543
+ try {
3544
+ const pi = createFakePi();
3545
+ piUsereqExtension(pi);
3546
+ const ctx = createFakeCtx(projectBase);
3547
+ await pi.emit("session_start", { reason: "startup" }, ctx);
3548
+
3549
+ const originalSessionFile = ctx.sessionManager.getSessionFile();
3550
+ const originalSessionCwd = ctx.sessionManager.getCwd();
3551
+ const originalSwitchSession = ctx.switchSession.bind(ctx);
3552
+ ctx.switchSession = async (sessionPath: string) => {
3553
+ // Mirror the real pi SDK: session switching runs internally but ctx.sessionManager stays frozen.
3554
+ const result = await originalSwitchSession(sessionPath);
3555
+ ctx.sessionManager.getSessionFile = () => originalSessionFile;
3556
+ ctx.sessionManager.getCwd = () => originalSessionCwd;
3557
+ return result;
3558
+ };
3559
+
3560
+ await pi.commands.get("req-change")!.handler("Adjust docs", ctx);
3561
+
3562
+ assert.equal(pi.sentUserMessages.length, 1);
3563
+ const promptText = String(pi.sentUserMessages[0]?.content ?? "");
3564
+ const worktreeMatch = promptText.match(/created worktree-dir `([^`]+)` and prepared context-path `([^`]+)`\./);
3565
+ assert.ok(worktreeMatch, promptText);
3566
+ const executionBasePath = worktreeMatch?.[2] ?? "";
3567
+ assert.equal(process.cwd(), executionBasePath);
3568
+ assert.equal(ctx.sessionManager.getSessionFile(), originalSessionFile);
3569
+ assert.equal(ctx.sessionManager.getCwd(), originalSessionCwd);
3570
+ assert.equal(
3571
+ ctx.__state.notifications.some((entry) => /Prompt execution activation expected/.test(entry.message)),
3572
+ false,
3573
+ );
3574
+ } finally {
3575
+ process.chdir(previousCwd);
3576
+ fs.rmSync(projectBase, { recursive: true, force: true });
3577
+ }
3578
+ });
3579
+
3580
+ test("worktree-backed prompt commands tolerate a pending-persistence execution-session file when process.cwd() is aligned with worktree-path", async () => {
3581
+ const { projectBase } = initFixtureRepo({ fixtures: [] });
3582
+ const previousCwd = process.cwd();
3583
+ try {
3584
+ const pi = createFakePi();
3585
+ piUsereqExtension(pi);
3586
+ const ctx = createFakeCtx(projectBase);
3587
+ await pi.emit("session_start", { reason: "startup" }, ctx);
3588
+
3589
+ const originalSwitchSession = ctx.switchSession.bind(ctx);
3590
+ ctx.switchSession = async (sessionPath: string) => {
3591
+ const result = await originalSwitchSession(sessionPath);
3592
+ // 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.
3593
+ if (typeof sessionPath === "string" && fs.existsSync(sessionPath)) {
3594
+ fs.rmSync(sessionPath, { force: true });
3595
+ }
3596
+ return result;
3597
+ };
3598
+
3599
+ await pi.commands.get("req-change")!.handler("Adjust docs", ctx);
3600
+
3601
+ assert.equal(pi.sentUserMessages.length, 1);
3602
+ const promptText = String(pi.sentUserMessages[0]?.content ?? "");
3603
+ const worktreeMatch = promptText.match(/created worktree-dir `([^`]+)` and prepared context-path `([^`]+)`\./);
3604
+ assert.ok(worktreeMatch, promptText);
3605
+ const executionBasePath = worktreeMatch?.[2] ?? "";
3606
+ assert.equal(process.cwd(), executionBasePath);
3607
+ assert.equal(
3608
+ ctx.__state.notifications.some((entry) => /Prompt execution activation expected session/.test(entry.message)),
3609
+ false,
3610
+ );
3611
+ } finally {
3612
+ process.chdir(previousCwd);
3613
+ fs.rmSync(projectBase, { recursive: true, force: true });
3614
+ }
3615
+ });
3616
+
3617
+ test("worktree-backed prompt commands abort before prompt dispatch when the persisted execution-session header cwd diverges from worktree-path", async () => {
3618
+ const { projectBase } = initFixtureRepo({ fixtures: [] });
3619
+ const previousCwd = process.cwd();
3620
+ try {
3621
+ const pi = createFakePi();
3622
+ piUsereqExtension(pi);
3623
+ const ctx = createFakeCtx(projectBase);
3624
+ const originalSwitchSession = ctx.switchSession.bind(ctx);
3625
+ ctx.switchSession = async (sessionPath: string) => {
3626
+ const result = await originalSwitchSession(sessionPath);
3627
+ // Simulate pi runtimes that persist a tampered header cwd, forcing the on-disk cwd verification to fail.
3628
+ if (typeof sessionPath === "string" && fs.existsSync(sessionPath)) {
3629
+ const tamperedHeader = `${JSON.stringify({ type: "session", cwd: projectBase })}\n`;
3630
+ fs.writeFileSync(sessionPath, tamperedHeader, "utf8");
3631
+ }
3632
+ return result;
3633
+ };
3634
+
3635
+ await assert.rejects(
3636
+ pi.commands.get("req-change")!.handler("Adjust docs", ctx),
3637
+ /Prompt execution activation expected session/,
3638
+ );
3639
+
3640
+ assert.equal(pi.sentUserMessages.length, 0);
3641
+ assert.ok(ctx.__state.notifications.some((entry) => /Prompt execution activation expected session/.test(entry.message)));
3642
+ } finally {
3643
+ process.chdir(previousCwd);
3644
+ fs.rmSync(projectBase, { recursive: true, force: true });
3645
+ }
3646
+ });
3647
+
3648
+ test("worktree-backed successful merge stops before merge when fork-session verification cannot reattach", async () => {
3649
+ const { projectBase } = initFixtureRepo({ fixtures: [] });
3650
+ const previousCwd = process.cwd();
3651
+ try {
3652
+ const pi = createFakePi();
3653
+ piUsereqExtension(pi);
3654
+ const ctx = createFakeCtx(projectBase);
3655
+ await pi.emit("session_start", { reason: "startup" }, ctx);
3656
+
3657
+ await pi.commands.get("req-change")!.handler("Adjust docs", ctx);
3658
+ const promptText = String(pi.sentUserMessages[0]?.content ?? "");
3659
+ const worktreeMatch = promptText.match(/created worktree-dir `([^`]+)` and prepared context-path `([^`]+)`\./);
3660
+ assert.ok(worktreeMatch, promptText);
3661
+ const executionSessionFile = ctx.sessionManager.getSessionFile();
3662
+ const executionBasePath = worktreeMatch?.[2] ?? "";
3663
+
3664
+ await pi.emit("before_agent_start", {}, ctx);
3665
+ await pi.emit("agent_start", {}, ctx);
3666
+ fs.mkdirSync(path.join(executionBasePath, "src"), { recursive: true });
3667
+ fs.writeFileSync(path.join(executionBasePath, "src", "restore-failed.ts"), "export const RESTORE_FAILED = 1;\n", "utf8");
3668
+ assert.equal(spawnSync("git", ["add", "src/restore-failed.ts"], { cwd: executionBasePath, encoding: "utf8" }).status, 0);
3669
+ const worktreeCommit = spawnSync("git", ["commit", "-m", "worktree change"], { cwd: executionBasePath, encoding: "utf8" });
3670
+ assert.equal(worktreeCommit.status, 0, worktreeCommit.stderr);
3671
+
3672
+ // Simulate a pi runtime that rewrites the execution-session header with a divergent cwd at prompt-end so closure verification must abort before merge.
3673
+ assert.equal(typeof executionSessionFile, "string");
3674
+ if (typeof executionSessionFile === "string" && fs.existsSync(executionSessionFile)) {
3675
+ const tamperedHeader = `${JSON.stringify({ type: "session", cwd: projectBase })}\n`;
3676
+ fs.writeFileSync(executionSessionFile, tamperedHeader, "utf8");
3677
+ }
3678
+ // Make ctx.sessionManager report a stale session file path so base-path restoration performs an explicit switch attempt after finalization verification fails.
3679
+ ctx.sessionManager.getSessionFile = () => path.join(projectBase, "stale-execution-session.jsonl");
3680
+ ctx.switchSession = async (_sessionPath: string) => ({ cancelled: false });
3681
+
3682
+ await pi.emit("agent_end", {
3683
+ messages: [{ role: "assistant", stopReason: "stop", content: [] }],
3684
+ }, ctx);
3685
+
3686
+ assert.equal(process.cwd(), projectBase);
3687
+ assert.equal(ctx.cwd, projectBase);
3688
+ assert.equal(fs.existsSync(executionBasePath), true);
3689
+ assert.equal(fs.existsSync(path.join(projectBase, "src", "restore-failed.ts")), false);
3690
+ assert.ok(ctx.__state.notifications.some((entry) => /Prompt execution finalization expected session/.test(entry.message)));
3691
+ } finally {
3692
+ process.chdir(previousCwd);
3693
+ fs.rmSync(projectBase, { recursive: true, force: true });
3694
+ }
3695
+ });
3696
+
3697
+ /**
3698
+ * @brief Verifies successful closure merges from `base-path` even when end-of-session timing already moved the persisted replacement context off the worktree session.
3699
+ * @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.
3700
+ * @return {Promise<void>} Promise resolved after closure assertions complete.
3701
+ * @throws {AssertionError} Throws when closure attempts an execution-session reattach or skips the merge plus cleanup.
3702
+ * @satisfies REQ-208, REQ-209, REQ-282, TST-094
3703
+ */
3704
+ test("worktree-backed closure merges from base-path when end-of-session timing already moved the persisted context", async () => {
3705
+ const { projectBase } = initFixtureRepo({ fixtures: [] });
3706
+ const previousCwd = process.cwd();
3707
+ try {
3708
+ writeProjectConfigOverrides(projectBase, {
3709
+ DEBUG_ENABLED: "enable",
3710
+ DEBUG_LOG_FILE: "prompt-debug.json",
3711
+ DEBUG_STATUS_CHANGES: "enable",
3712
+ DEBUG_WORKFLOW_EVENTS: "enable",
3713
+ DEBUG_LOG_ON_STATUS: "any",
3714
+ DEBUG_ENABLED_PROMPTS: ["req-change"],
3715
+ });
3716
+ const pi = createFakePi();
3717
+ piUsereqExtension(pi);
3718
+ const commandCtx = createFakeCtx(projectBase);
3719
+ await pi.emit("session_start", { reason: "startup" }, commandCtx);
3720
+
3721
+ const originalSessionFile = commandCtx.sessionManager.getSessionFile() ?? "";
3722
+ await pi.commands.get("req-change")!.handler("Adjust docs", commandCtx);
3723
+ const promptText = String(pi.sentUserMessages[0]?.content ?? "");
3724
+ const worktreeMatch = promptText.match(/created worktree-dir `([^`]+)` and prepared context-path `([^`]+)`\./);
3725
+ assert.ok(worktreeMatch, promptText);
3726
+ const worktreeName = worktreeMatch?.[1] ?? "";
3727
+ const executionBasePath = worktreeMatch?.[2] ?? "";
3728
+ const executionSessionFile = commandCtx.sessionManager.getSessionFile() ?? "";
3729
+ assert.notEqual(executionSessionFile, "");
3730
+
3731
+ await pi.emit("before_agent_start", {}, commandCtx);
3732
+ await pi.emit("agent_start", {}, commandCtx);
3733
+ fs.mkdirSync(path.join(executionBasePath, "src"), { recursive: true });
3734
+ fs.writeFileSync(path.join(executionBasePath, "src", "base-merge.ts"), "export const BASE_MERGE = 1;\n", "utf8");
3735
+ assert.equal(spawnSync("git", ["add", "src/base-merge.ts"], { cwd: executionBasePath, encoding: "utf8" }).status, 0);
3736
+ const worktreeCommit = spawnSync("git", ["commit", "-m", "base merge"], { cwd: executionBasePath, encoding: "utf8" });
3737
+ assert.equal(worktreeCommit.status, 0, worktreeCommit.stderr);
3738
+
3739
+ const persistedContext = readPersistedPromptCommandSessionContext(executionSessionFile) as any;
3740
+ assert.ok(persistedContext);
3741
+ persistedContext.cwd = projectBase;
3742
+ persistedContext.sessionManager.getSessionFile = () => originalSessionFile;
3743
+ persistedContext.sessionManager.getSessionDir = () => path.dirname(originalSessionFile);
3744
+ persistedContext.sessionManager.getCwd = () => projectBase;
3745
+ persistedContext.switchSession = async (sessionPath: string) => {
3746
+ if (path.resolve(sessionPath) !== path.resolve(originalSessionFile)) {
3747
+ throw new Error(`Unexpected execution-session reattach: ${sessionPath}`);
3748
+ }
3749
+ process.chdir(projectBase);
3750
+ return { cancelled: false };
3751
+ };
3752
+
3753
+ const eventCtx = createFakeCtx(projectBase);
3754
+ eventCtx.sessionManager.getSessionFile = () => originalSessionFile;
3755
+ eventCtx.sessionManager.getSessionDir = () => path.dirname(originalSessionFile);
3756
+ eventCtx.sessionManager.getCwd = () => projectBase;
3757
+ eventCtx.cwd = projectBase;
3758
+ delete eventCtx.switchSession;
3759
+ process.chdir(projectBase);
3760
+
3761
+ await pi.emit("agent_end", {
3762
+ messages: [{ role: "assistant", stopReason: "stop", content: [] }],
3763
+ }, eventCtx);
3764
+
3765
+ const debugLog = JSON.parse(fs.readFileSync(path.join(projectBase, "prompt-debug.json"), "utf8"));
3766
+ assert.ok(debugLog.some((entry: any) => entry.action === "workflow_restore" && entry.result?.success === true));
3767
+ assert.ok(debugLog.some((entry: any) => entry.action === "merge" && entry.result?.success === true));
3768
+ assert.equal(process.cwd(), projectBase);
3769
+ assert.equal(eventCtx.cwd, projectBase);
3770
+ assert.ok(fs.existsSync(path.join(projectBase, "src", "base-merge.ts")));
3771
+ assert.equal(fs.existsSync(executionBasePath), false);
3772
+ assert.notEqual(
3773
+ spawnSync("git", ["show-ref", "--verify", "--quiet", `refs/heads/${worktreeName}`], {
3774
+ cwd: projectBase,
3775
+ encoding: "utf8",
3776
+ }).status,
3777
+ 0,
3778
+ );
3779
+ assert.equal(eventCtx.__state.notifications.filter((entry) => entry.level === "error").length, 0);
3780
+ } finally {
3781
+ process.chdir(previousCwd);
3782
+ fs.rmSync(projectBase, { recursive: true, force: true });
3783
+ }
3784
+ });
1568
3785
 
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");
3786
+ /**
3787
+ * @brief Verifies matched successful closure ignores stale session-switch errors after restored base-path verification.
3788
+ * @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.
3789
+ * @return {Promise<void>} Promise resolved after restored-base verification, merge, and cleanup assertions complete.
3790
+ * @throws {AssertionError} Throws when the stale restore-switch error prevents merge plus cleanup.
3791
+ * @satisfies REQ-208, REQ-276, REQ-280, REQ-282
3792
+ */
3793
+ test("worktree-backed closure ignores stale restore-switch errors after base-path verification", async () => {
3794
+ const { projectBase } = initFixtureRepo({ fixtures: [] });
3795
+ const previousCwd = process.cwd();
3796
+ try {
3797
+ writeProjectConfigOverrides(projectBase, {
3798
+ DEBUG_ENABLED: "enable",
3799
+ DEBUG_LOG_FILE: "prompt-debug.json",
3800
+ DEBUG_STATUS_CHANGES: "enable",
3801
+ DEBUG_WORKFLOW_EVENTS: "enable",
3802
+ DEBUG_LOG_ON_STATUS: "any",
3803
+ DEBUG_ENABLED_PROMPTS: ["req-change"],
3804
+ });
3805
+ const pi = createFakePi();
3806
+ piUsereqExtension(pi);
3807
+ const commandCtx = createFakeCtx(projectBase);
3808
+ await pi.emit("session_start", { reason: "startup" }, commandCtx);
3809
+
3810
+ const originalSessionFile = commandCtx.sessionManager.getSessionFile() ?? "";
3811
+ await pi.commands.get("req-change")!.handler("Adjust docs", commandCtx);
3812
+ const promptText = String(pi.sentUserMessages[0]?.content ?? "");
3813
+ const worktreeMatch = promptText.match(/created worktree-dir `([^`]+)` and prepared context-path `([^`]+)`\./);
3814
+ assert.ok(worktreeMatch, promptText);
3815
+ const worktreeName = worktreeMatch?.[1] ?? "";
3816
+ const executionBasePath = worktreeMatch?.[2] ?? "";
3817
+ const executionSessionFile = commandCtx.sessionManager.getSessionFile() ?? "";
3818
+ assert.notEqual(executionSessionFile, "");
3819
+
3820
+ await pi.emit("before_agent_start", {}, commandCtx);
3821
+ await pi.emit("agent_start", {}, commandCtx);
3822
+ fs.mkdirSync(path.join(executionBasePath, "src"), { recursive: true });
3823
+ fs.writeFileSync(path.join(executionBasePath, "src", "stale-restore.ts"), "export const STALE_RESTORE = 1;\n", "utf8");
3824
+ assert.equal(spawnSync("git", ["add", "src/stale-restore.ts"], { cwd: executionBasePath, encoding: "utf8" }).status, 0);
3825
+ const worktreeCommit = spawnSync("git", ["commit", "-m", "stale restore"], { cwd: executionBasePath, encoding: "utf8" });
3826
+ assert.equal(worktreeCommit.status, 0, worktreeCommit.stderr);
3827
+
3828
+ const persistedContext = readPersistedPromptCommandSessionContext(executionSessionFile) as any;
3829
+ assert.ok(persistedContext);
3830
+ persistedContext.cwd = projectBase;
3831
+ persistedContext.sessionManager.getSessionFile = () => originalSessionFile;
3832
+ persistedContext.sessionManager.getSessionDir = () => path.dirname(originalSessionFile);
3833
+ persistedContext.sessionManager.getCwd = () => projectBase;
3834
+ persistedContext.switchSession = async (sessionPath: string) => {
3835
+ assert.equal(path.resolve(sessionPath), path.resolve(originalSessionFile));
3836
+ process.chdir(projectBase);
3837
+ persistedContext.cwd = projectBase;
3838
+ throw new Error("This extension ctx is stale after session replacement or reload.");
3839
+ };
3840
+
3841
+ const eventCtx = createFakeCtx(projectBase);
3842
+ eventCtx.sessionManager.getSessionFile = () => originalSessionFile;
3843
+ eventCtx.sessionManager.getSessionDir = () => path.dirname(originalSessionFile);
3844
+ eventCtx.sessionManager.getCwd = () => projectBase;
3845
+ eventCtx.cwd = projectBase;
3846
+ delete eventCtx.switchSession;
3847
+ process.chdir(projectBase);
3848
+
3849
+ await pi.emit("agent_end", {
3850
+ messages: [{ role: "assistant", stopReason: "stop", content: [] }],
3851
+ }, eventCtx);
3852
+
3853
+ const debugLog = JSON.parse(fs.readFileSync(path.join(projectBase, "prompt-debug.json"), "utf8"));
3854
+ assert.ok(debugLog.some((entry: any) => entry.action === "workflow_restore" && entry.result?.success === true));
3855
+ assert.ok(debugLog.some((entry: any) => entry.action === "merge" && entry.result?.success === true));
3856
+ assert.equal(process.cwd(), projectBase);
3857
+ assert.equal(eventCtx.cwd, projectBase);
3858
+ assert.equal(fs.existsSync(executionBasePath), false);
3859
+ assert.equal(fs.existsSync(path.join(projectBase, "src", "stale-restore.ts")), true);
3860
+ assert.notEqual(
3861
+ spawnSync("git", ["show-ref", "--verify", "--quiet", `refs/heads/${worktreeName}`], {
3862
+ cwd: projectBase,
3863
+ encoding: "utf8",
3864
+ }).status,
3865
+ 0,
3866
+ );
3867
+ assert.equal(eventCtx.__state.notifications.filter((entry) => entry.level === "error").length, 0);
3868
+ } finally {
3869
+ process.chdir(previousCwd);
3870
+ fs.rmSync(projectBase, { recursive: true, force: true });
3871
+ }
1574
3872
  });
1575
3873
 
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
- });
3874
+ test("worktree-backed prompt commands abort before prompt dispatch when post-create verification fails", async () => {
3875
+ const { projectBase } = initFixtureRepo({ fixtures: [] });
3876
+ const previousCwd = process.cwd();
3877
+ const originalExistsSync = fs.existsSync;
3878
+ let failedWorktreeName = "";
3879
+ let failedWorktreePath = "";
3880
+ try {
3881
+ const pi = createFakePi();
3882
+ piUsereqExtension(pi);
3883
+ const ctx = createFakeCtx(projectBase);
3884
+ setPromptCommandPostCreateHookForTests(({ worktreeDir, worktreeRootPath, worktreePath }) => {
3885
+ failedWorktreeName = worktreeDir;
3886
+ failedWorktreePath = worktreePath;
3887
+ fs.existsSync = ((candidatePath: Parameters<typeof fs.existsSync>[0]) => {
3888
+ const normalizedPath = typeof candidatePath === "string" ? candidatePath : String(candidatePath);
3889
+ if (normalizedPath === worktreePath || normalizedPath === worktreeRootPath) {
3890
+ return false;
3891
+ }
3892
+ return originalExistsSync(candidatePath);
3893
+ }) as typeof fs.existsSync;
3894
+ });
1599
3895
 
1600
- await command!.handler("", ctx);
3896
+ await assert.rejects(
3897
+ pi.commands.get("req-change")!.handler("Adjust docs", ctx),
3898
+ /ERROR: Worktree verification failed/,
3899
+ );
1601
3900
 
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"]);
3901
+ assert.equal(pi.sentUserMessages.length, 0);
3902
+ fs.existsSync = originalExistsSync;
3903
+ assert.equal(fs.existsSync(failedWorktreePath), false);
3904
+ assert.notEqual(failedWorktreeName, "");
3905
+ assert.notEqual(
3906
+ spawnSync("git", ["show-ref", "--verify", "--quiet", `refs/heads/${failedWorktreeName}`], {
3907
+ cwd: projectBase,
3908
+ encoding: "utf8",
3909
+ }).status,
3910
+ 0,
3911
+ );
3912
+ assert.ok(ctx.__state.notifications.some((entry) => /Worktree verification failed/.test(entry.message)));
3913
+ } finally {
3914
+ fs.existsSync = originalExistsSync;
3915
+ setPromptCommandPostCreateHookForTests(undefined);
3916
+ process.chdir(previousCwd);
3917
+ fs.rmSync(projectBase, { recursive: true, force: true });
3918
+ }
1609
3919
  });
1610
3920
 
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);
3921
+ test("prompt commands remain functional when custom tool registrations are removed from the runtime inventory", async () => {
3922
+ const { projectBase } = initFixtureRepo({ fixtures: [] });
3923
+ try {
3924
+ writeProjectConfigOverrides(projectBase, { GIT_WORKTREE_ENABLED: "disable" });
3925
+ const pi = createFakePi();
3926
+ piUsereqExtension(pi);
3927
+ pi.tools.splice(0, pi.tools.length);
3928
+ const ctx = createFakeCtx(projectBase);
1618
3929
 
1619
- await command!.handler("Inspect src/index.ts for prompt coverage", ctx);
3930
+ await pi.commands.get("req-analyze")!.handler("Inspect src/index.ts", ctx);
1620
3931
 
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/);
3932
+ assert.equal(pi.sentUserMessages.length, 1);
3933
+ assert.match(String(pi.sentUserMessages[0]?.content ?? ""), /Inspect src\/index\.ts/);
3934
+ } finally {
3935
+ fs.rmSync(projectBase, { recursive: true, force: true });
3936
+ }
1625
3937
  });
1626
3938
 
1627
3939
  test("session_start applies configured pi-usereq startup tools", async () => {
@@ -1634,7 +3946,7 @@ test("session_start applies configured pi-usereq startup tools", async () => {
1634
3946
  "tests-dir": "tests",
1635
3947
  "src-dir": ["src", "foobar"],
1636
3948
  "static-check": {},
1637
- "enabled-tools": ["git-path", "static-check"],
3949
+ "enabled-tools": ["files-tokens", "static-check"],
1638
3950
  }, null, 2)}\n`,
1639
3951
  "utf8",
1640
3952
  );
@@ -1645,7 +3957,7 @@ test("session_start applies configured pi-usereq startup tools", async () => {
1645
3957
  await pi.emit("session_start", { reason: "startup" }, ctx);
1646
3958
 
1647
3959
  const activeTools = new Set(pi.getActiveTools());
1648
- assert.deepEqual([...activeTools].sort(), ["git-path", "static-check"]);
3960
+ assert.deepEqual([...activeTools].sort(), ["files-tokens", "static-check"]);
1649
3961
  assert.equal(
1650
3962
  ctx.__state.statuses.get("pi-usereq"),
1651
3963
  buildExpectedFakeStatusText({
@@ -1684,6 +3996,33 @@ test("session_start renders the 0-percent context icon when context usage is emp
1684
3996
  );
1685
3997
  });
1686
3998
 
3999
+ /**
4000
+ * @brief Verifies stale replacement contexts do not break workflow-state rendering.
4001
+ * @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.
4002
+ * @return {void} No return value.
4003
+ * @throws {AssertionError} Throws when stale-context rendering escapes or leaves the stale context cached for later renders.
4004
+ * @satisfies REQ-280, TST-092
4005
+ */
4006
+ test("stale replacement contexts do not break workflow-state rendering", () => {
4007
+ const cwd = createTempDir("pi-usereq-stale-render-");
4008
+ fs.mkdirSync(path.dirname(getProjectConfigPath(cwd)), { recursive: true });
4009
+ const controller = createPiUsereqStatusController();
4010
+ setPiUsereqStatusConfig(controller, getDefaultConfig());
4011
+ const staleCtx = createFakeCtx(cwd);
4012
+ Object.defineProperty(staleCtx, "ui", {
4013
+ configurable: true,
4014
+ get() {
4015
+ throw new Error("This extension instance is stale after session replacement or reload. Use the provided replacement-session context instead.");
4016
+ },
4017
+ });
4018
+
4019
+ assert.doesNotThrow(() => {
4020
+ setPiUsereqWorkflowState(controller, "running", staleCtx);
4021
+ });
4022
+ assert.equal(controller.state.workflowState, "running");
4023
+ assert.equal(controller.latestContext, undefined);
4024
+ });
4025
+
1687
4026
  test("extension registers wrappers for all pi-usereq status hooks", () => {
1688
4027
  const pi = createFakePi();
1689
4028
  piUsereqExtension(pi);
@@ -1751,7 +4090,47 @@ test("context hook refreshes context usage and rounds progress cells upward", as
1751
4090
  );
1752
4091
  });
1753
4092
 
1754
- test("context hook renders the full context icon when usage exceeds ninety percent", async () => {
4093
+ /**
4094
+ * @brief Verifies context-gauge icon cutovers below the error range.
4095
+ * @details Replays deterministic context percentages at documented threshold boundaries below `90%` so the status bar proves the `▕▂▏`, `▕▄▏`, `▕▆▏`, and `▕█▏` transitions while keeping the default-color contract intact. Runtime is O(n) in case count. Side effects are limited to in-memory status updates.
4096
+ * @return {Promise<void>} Promise resolved after all threshold assertions complete.
4097
+ * @throws {AssertionError} Throws when any threshold renders the wrong icon.
4098
+ * @satisfies TST-096
4099
+ */
4100
+ test("context hook applies quarter-threshold gauge icons below the error range", async () => {
4101
+ const cwd = createTempDir("pi-usereq-context-threshold-status-");
4102
+ fs.mkdirSync(path.dirname(getProjectConfigPath(cwd)), { recursive: true });
4103
+ const contextUsage = { tokens: 0, contextWindow: 1000, percent: 0 };
4104
+ const pi = createFakePi();
4105
+ piUsereqExtension(pi);
4106
+ const ctx = createFakeCtx(cwd, { selects: [] }, {
4107
+ getContextUsage: () => ({ ...contextUsage }),
4108
+ });
4109
+
4110
+ await pi.emit("session_start", { reason: "startup" }, ctx);
4111
+
4112
+ for (const percent of [24.9, 25, 49.9, 50, 74.9, 75, 89.9]) {
4113
+ contextUsage.tokens = Math.round(percent * 10);
4114
+ contextUsage.percent = percent;
4115
+ await pi.emit("context", { messages: [] }, ctx);
4116
+
4117
+ assert.equal(
4118
+ ctx.__state.statuses.get("pi-usereq"),
4119
+ buildExpectedFakeStatusText({
4120
+ basePath: buildExpectedFakeBasePath(cwd),
4121
+ docsDir: DEFAULT_DOCS_DIR,
4122
+ testsDir: "tests",
4123
+ srcDir: ["src"],
4124
+ contextFilledCells: Math.ceil(percent / 10),
4125
+ contextPercent: percent,
4126
+ et: "⏱︎ --:-- ⚑ --:-- ⌛︎--:--",
4127
+ }),
4128
+ `unexpected context icon at ${percent}%`,
4129
+ );
4130
+ }
4131
+ });
4132
+
4133
+ test("context hook renders the error full context icon when usage exceeds ninety percent", async () => {
1755
4134
  const cwd = createTempDir("pi-usereq-context-full-status-");
1756
4135
  fs.mkdirSync(path.dirname(getProjectConfigPath(cwd)), { recursive: true });
1757
4136
  const contextUsage = { tokens: 910, contextWindow: 1000, percent: 91 };
@@ -1778,14 +4157,14 @@ test("context hook renders the full context icon when usage exceeds ninety perce
1778
4157
  );
1779
4158
  });
1780
4159
 
1781
- test("status overflow context icon uses only CLI-supported theme tokens", async () => {
4160
+ test("status overflow-threshold context icon uses only CLI-supported theme tokens", async () => {
1782
4161
  const cwd = createTempDir("pi-usereq-context-theme-contract-");
1783
4162
  fs.mkdirSync(path.dirname(getProjectConfigPath(cwd)), { recursive: true });
1784
4163
  const usedColors = new Set<string>();
1785
4164
  const pi = createFakePi();
1786
4165
  piUsereqExtension(pi);
1787
4166
  const ctx = createFakeCtx(cwd, { selects: [] }, {
1788
- getContextUsage: () => ({ tokens: 1250, contextWindow: 1000, percent: 125 }),
4167
+ getContextUsage: () => ({ tokens: 1000, contextWindow: 1000, percent: 100 }),
1789
4168
  theme: {
1790
4169
  fg(color: string, text: string) {
1791
4170
  assert.ok(["accent", "warning", "dim", "error"].includes(color));
@@ -1814,7 +4193,7 @@ test("status overflow context icon uses only CLI-supported theme tokens", async
1814
4193
  testsDir: "tests",
1815
4194
  srcDir: ["src"],
1816
4195
  contextFilledCells: 10,
1817
- contextPercent: 125,
4196
+ contextPercent: 100,
1818
4197
  et: "⏱︎ --:-- ⚑ --:-- ⌛︎--:--",
1819
4198
  }),
1820
4199
  );
@@ -1924,6 +4303,71 @@ test("agent timing status updates et while preserving accumulated completed runt
1924
4303
  }
1925
4304
  });
1926
4305
 
4306
+ test("elapsed counters persist across new-session rebinding and reset on reload", async () => {
4307
+ const cwd = createTempDir("pi-usereq-timing-rebind-");
4308
+ fs.mkdirSync(path.dirname(getProjectConfigPath(cwd)), { recursive: true });
4309
+ const originalDateNow = Date.now;
4310
+ let nowMs = 0;
4311
+ Date.now = () => nowMs;
4312
+
4313
+ try {
4314
+ const firstPi = createFakePi();
4315
+ piUsereqExtension(firstPi);
4316
+ const firstCtx = createFakeCtx(cwd);
4317
+ await firstPi.emit("session_start", { reason: "startup" }, firstCtx);
4318
+ await firstPi.emit("agent_start", {}, firstCtx);
4319
+ nowMs = 61_000;
4320
+ await firstPi.emit("agent_end", {
4321
+ messages: [
4322
+ {
4323
+ role: "assistant",
4324
+ stopReason: "stop",
4325
+ content: [],
4326
+ },
4327
+ ],
4328
+ }, firstCtx);
4329
+ await firstPi.emit("session_shutdown", {}, firstCtx);
4330
+
4331
+ const newPi = createFakePi();
4332
+ piUsereqExtension(newPi);
4333
+ const newCtx = createFakeCtx(cwd);
4334
+ await newPi.emit("session_start", {
4335
+ reason: "new",
4336
+ previousSessionFile: "/tmp/previous-session.jsonl",
4337
+ }, newCtx);
4338
+ assert.equal(
4339
+ newCtx.__state.statuses.get("pi-usereq"),
4340
+ buildExpectedFakeStatusText({
4341
+ basePath: buildExpectedFakeBasePath(cwd),
4342
+ docsDir: DEFAULT_DOCS_DIR,
4343
+ testsDir: "tests",
4344
+ srcDir: ["src"],
4345
+ contextFilledCells: 0,
4346
+ et: "⏱︎ --:-- ⚑ 1:01 ⌛︎1:01",
4347
+ }),
4348
+ );
4349
+ await newPi.emit("session_shutdown", {}, newCtx);
4350
+
4351
+ const reloadPi = createFakePi();
4352
+ piUsereqExtension(reloadPi);
4353
+ const reloadCtx = createFakeCtx(cwd);
4354
+ await reloadPi.emit("session_start", { reason: "reload" }, reloadCtx);
4355
+ assert.equal(
4356
+ reloadCtx.__state.statuses.get("pi-usereq"),
4357
+ buildExpectedFakeStatusText({
4358
+ basePath: buildExpectedFakeBasePath(cwd),
4359
+ docsDir: DEFAULT_DOCS_DIR,
4360
+ testsDir: "tests",
4361
+ srcDir: ["src"],
4362
+ contextFilledCells: 0,
4363
+ et: "⏱︎ --:-- ⚑ --:-- ⌛︎--:--",
4364
+ }),
4365
+ );
4366
+ } finally {
4367
+ Date.now = originalDateNow;
4368
+ }
4369
+ });
4370
+
1927
4371
  test("session_start enables default custom tools and default embedded tools only", async () => {
1928
4372
  const cwd = createTempDir("pi-usereq-default-tools-");
1929
4373
  fs.mkdirSync(path.dirname(getProjectConfigPath(cwd)), { recursive: true });
@@ -1936,14 +4380,67 @@ test("session_start enables default custom tools and default embedded tools only
1936
4380
  for (const toolName of PI_USEREQ_DEFAULT_ENABLED_TOOL_NAMES) {
1937
4381
  assert.ok(activeTools.has(toolName), `missing default active tool ${toolName}`);
1938
4382
  }
4383
+ assert.equal(activeTools.has("search"), true);
4384
+ assert.equal(activeTools.has("files-search"), true);
1939
4385
  assert.equal(activeTools.has("find"), false);
1940
4386
  assert.equal(activeTools.has("grep"), false);
1941
4387
  assert.equal(activeTools.has("ls"), false);
1942
4388
  });
1943
4389
 
1944
- test("default configuration applies the documented notify, sound, and pushover defaults", () => {
4390
+ test("default configuration applies the documented static-check, debug, notify, sound, and pushover defaults", () => {
1945
4391
  const config = getDefaultConfig(createTempDir("pi-usereq-default-notify-"));
1946
4392
 
4393
+ assert.equal(config.AUTO_GIT_COMMIT, "enable");
4394
+ assert.deepEqual(Object.keys(config["static-check"]).length, 20);
4395
+ assert.deepEqual(config["static-check"].C, createStaticCheckLanguageConfig([
4396
+ {
4397
+ module: "Command",
4398
+ cmd: "cppcheck",
4399
+ params: [
4400
+ "--error-exitcode=1",
4401
+ "--enable=warning,style,performance,portability",
4402
+ "--std=c11",
4403
+ ],
4404
+ },
4405
+ {
4406
+ module: "Command",
4407
+ cmd: "clang-format",
4408
+ params: ["--dry-run", "--Werror"],
4409
+ },
4410
+ ], "enable"));
4411
+ assert.deepEqual(config["static-check"]["C++"], createStaticCheckLanguageConfig([
4412
+ {
4413
+ module: "Command",
4414
+ cmd: "cppcheck",
4415
+ params: [
4416
+ "--error-exitcode=1",
4417
+ "--enable=warning,style,performance,portability",
4418
+ "--std=c++20",
4419
+ ],
4420
+ },
4421
+ {
4422
+ module: "Command",
4423
+ cmd: "clang-format",
4424
+ params: ["--dry-run", "--Werror"],
4425
+ },
4426
+ ], "enable"));
4427
+ assert.deepEqual(config["static-check"].Python, createStaticCheckLanguageConfig([
4428
+ { module: "Command", cmd: "pyright", params: ["--outputjson"] },
4429
+ { module: "Command", cmd: "ruff", params: ["check"] },
4430
+ ], "enable"));
4431
+ assert.deepEqual(config["static-check"].JavaScript, createStaticCheckLanguageConfig([
4432
+ { module: "Command", cmd: "node", params: ["--check"] },
4433
+ ], "enable"));
4434
+ assert.deepEqual(config["static-check"].TypeScript, createStaticCheckLanguageConfig([
4435
+ { module: "Command", cmd: "npx", params: ["eslint"] },
4436
+ ], "enable"));
4437
+ assert.deepEqual(config["static-check"].Ruby, createStaticCheckLanguageConfig([], "disable"));
4438
+ assert.equal(config.DEBUG_ENABLED, "disable");
4439
+ assert.equal(config.DEBUG_LOG_FILE, DEFAULT_DEBUG_LOG_FILE);
4440
+ assert.equal(config.DEBUG_WORKFLOW_EVENTS, DEFAULT_DEBUG_WORKFLOW_EVENTS);
4441
+ assert.equal(config.DEBUG_LOG_ON_STATUS, DEFAULT_DEBUG_LOG_ON_STATUS);
4442
+ assert.deepEqual(config.DEBUG_ENABLED_TOOLS, []);
4443
+ assert.deepEqual(config.DEBUG_ENABLED_PROMPTS, []);
1947
4444
  assert.equal(config["notify-enabled"], false);
1948
4445
  assert.equal(config["notify-on-completed"], true);
1949
4446
  assert.equal(config["notify-on-interrupted"], false);
@@ -1960,22 +4457,40 @@ test("default configuration applies the documented notify, sound, and pushover d
1960
4457
  assert.equal(config["notify-pushover-text"], "%%RESULT%%\n%%ARGS%%");
1961
4458
  });
1962
4459
 
1963
- test("configuration menu can enable embedded builtin tools", async () => {
4460
+ test("configuration menu orders tool toggles and can enable embedded builtin tools", async () => {
1964
4461
  const cwd = createTempDir("pi-usereq-menu-embedded-");
1965
4462
  fs.mkdirSync(path.dirname(getProjectConfigPath(cwd)), { recursive: true });
1966
4463
  const pi = createFakePi();
1967
4464
  piUsereqExtension(pi);
1968
4465
  const command = pi.commands.get("pi-usereq");
1969
4466
  assert.ok(command);
4467
+ const ctx = createFakeCtx(cwd, {
4468
+ selects: ["Enable tools", "Enable tools", "grep", "Save and close", "Save and close"],
4469
+ });
1970
4470
 
1971
- await command!.handler(
1972
- "",
1973
- createFakeCtx(cwd, {
1974
- selects: ["Enable tools", "Enable tools", "grep", "Save and close", "Save and close"],
1975
- }),
1976
- );
4471
+ await command!.handler("", ctx);
1977
4472
 
1978
4473
  const config = JSON.parse(fs.readFileSync(getProjectConfigPath(cwd), "utf8"));
4474
+ assert.deepEqual(ctx.__state.selectCalls[2]?.items ?? [], [
4475
+ "compress",
4476
+ "references",
4477
+ "search",
4478
+ "static-check",
4479
+ "tokens",
4480
+ "files-compress",
4481
+ "files-references",
4482
+ "files-search",
4483
+ "files-static-check",
4484
+ "files-tokens",
4485
+ "bash",
4486
+ "edit",
4487
+ "read",
4488
+ "write",
4489
+ "find",
4490
+ "grep",
4491
+ "ls",
4492
+ "Reset defaults",
4493
+ ]);
1979
4494
  assert.ok(config["enabled-tools"].includes("grep"));
1980
4495
  assert.ok(pi.getActiveTools().includes("grep"));
1981
4496
  });
@@ -2076,6 +4591,8 @@ test("configuration menu can persist pushover settings", async () => {
2076
4591
  const ctx = createFakeCtx(cwd, {
2077
4592
  selects: [
2078
4593
  "notifications",
4594
+ "Pushover User Key/Delivery Group Key",
4595
+ "Pushover Token/API Token Key",
2079
4596
  "Enable pushover",
2080
4597
  "Pushover events",
2081
4598
  "Prompt failed",
@@ -2084,16 +4601,14 @@ test("configuration menu can persist pushover settings", async () => {
2084
4601
  "High",
2085
4602
  "Pushover title",
2086
4603
  "Pushover text",
2087
- "Pushover User Key/Delivery Group Key",
2088
- "Pushover Token/API Token Key",
2089
4604
  "Save and close",
2090
4605
  "Save and close",
2091
4606
  ],
2092
4607
  inputs: [
2093
- "%%PROMT%% @ %%BASE%% [%%TIME%%]",
2094
- "%%RESULT%%\n%%ARGS%%",
2095
4608
  "gzfjjvp1xxmhibqwzh9m7i1zwvf83j",
2096
4609
  "ah6bf5u2sj63mcvou6qamiabeoubbe",
4610
+ "%%PROMT%% @ %%BASE%% [%%TIME%%]",
4611
+ "%%RESULT%%\\n%%ARGS%%",
2097
4612
  ],
2098
4613
  });
2099
4614
 
@@ -2123,16 +4638,40 @@ test("configuration menu can persist pushover settings", async () => {
2123
4638
  );
2124
4639
  });
2125
4640
 
2126
- test("prompt-end pushover requests honor global enable, event toggles, credentials, priority, and templates", async () => {
2127
- const cwd = createTempDir("pi-usereq-pushover-run-");
4641
+ test("notifications menu escapes pushover text and locks pushover enablement until credentials exist", async () => {
4642
+ const cwd = createTempDir("pi-usereq-menu-pushover-display-");
2128
4643
  fs.mkdirSync(path.dirname(getProjectConfigPath(cwd)), { recursive: true });
2129
- const configPath = getProjectConfigPath(cwd);
2130
- const writeConfig = (overrides: Record<string, unknown>) => {
2131
- const config = {
4644
+ fs.writeFileSync(
4645
+ getProjectConfigPath(cwd),
4646
+ `${JSON.stringify({
2132
4647
  ...getDefaultConfig(cwd),
4648
+ "notify-pushover-text": "first line\nsecond line",
4649
+ }, null, 2)}\n`,
4650
+ "utf8",
4651
+ );
4652
+ const pi = createFakePi();
4653
+ piUsereqExtension(pi);
4654
+ const command = pi.commands.get("pi-usereq");
4655
+ assert.ok(command);
4656
+ const ctx = createFakeCtx(cwd, {
4657
+ selects: ["notifications", "Pushover text", "Save and close", "Save and close"],
4658
+ });
4659
+
4660
+ await command!.handler("", ctx);
4661
+
4662
+ const renderedNotificationsMenu = (ctx.__state.customRenderLines[2] ?? []).join("\n");
4663
+ assert.match(renderedNotificationsMenu, /first line\\nsecond line/);
4664
+ assert.match(renderedNotificationsMenu, /<dim>Enable pushover/);
4665
+ assert.match(renderedNotificationsMenu, /<dim>off<\/dim>/);
4666
+ });
4667
+
4668
+ test("prompt-end pushover requests honor global enable, event toggles, credentials, priority, and templates", async () => {
4669
+ const { projectBase: cwd } = initFixtureRepo({ fixtures: [] });
4670
+ const writeConfig = (overrides: Record<string, unknown>) => {
4671
+ writeProjectConfigOverrides(cwd, {
4672
+ GIT_WORKTREE_ENABLED: "disable",
2133
4673
  ...overrides,
2134
- };
2135
- fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8");
4674
+ });
2136
4675
  };
2137
4676
  const pi = createFakePi();
2138
4677
  piUsereqExtension(pi);
@@ -2308,12 +4847,12 @@ test("prompt-end pushover requests honor global enable, event toggles, credentia
2308
4847
  } finally {
2309
4848
  setPiNotifyHttpsRequestForTests(undefined);
2310
4849
  Date.now = originalDateNow;
4850
+ fs.rmSync(cwd, { recursive: true, force: true });
2311
4851
  }
2312
4852
  });
2313
4853
 
2314
4854
  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 });
4855
+ const { projectBase: cwd } = initFixtureRepo({ fixtures: [] });
2317
4856
  const pi = createFakePi();
2318
4857
  piUsereqExtension(pi);
2319
4858
  const command = pi.commands.get("pi-usereq");
@@ -2333,6 +4872,7 @@ test("pushover global enable controls status and suppresses delivery when disabl
2333
4872
  });
2334
4873
 
2335
4874
  await command!.handler("", ctx);
4875
+ writeProjectConfigOverrides(cwd, { GIT_WORKTREE_ENABLED: "disable" });
2336
4876
 
2337
4877
  const config = JSON.parse(fs.readFileSync(getProjectConfigPath(cwd), "utf8"));
2338
4878
  assert.equal(config["notify-pushover-enabled"], false);
@@ -2390,19 +4930,17 @@ test("pushover global enable controls status and suppresses delivery when disabl
2390
4930
  assert.deepEqual(recordedRequests, []);
2391
4931
  } finally {
2392
4932
  setPiNotifyHttpsRequestForTests(undefined);
4933
+ fs.rmSync(cwd, { recursive: true, force: true });
2393
4934
  }
2394
4935
  });
2395
4936
 
2396
4937
  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);
4938
+ const { projectBase: cwd } = initFixtureRepo({ fixtures: [] });
2400
4939
  const writeConfig = (overrides: Record<string, unknown>) => {
2401
- const config = {
2402
- ...getDefaultConfig(cwd),
4940
+ writeProjectConfigOverrides(cwd, {
4941
+ GIT_WORKTREE_ENABLED: "disable",
2403
4942
  ...overrides,
2404
- };
2405
- fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8");
4943
+ });
2406
4944
  };
2407
4945
  const pi = createFakePi();
2408
4946
  piUsereqExtension(pi);
@@ -2502,19 +5040,17 @@ test("command notify routes through PI_NOTIFY_CMD placeholders and per-event tog
2502
5040
  } finally {
2503
5041
  setPiNotifySpawnForTests(undefined);
2504
5042
  Date.now = originalDateNow;
5043
+ fs.rmSync(cwd, { recursive: true, force: true });
2505
5044
  }
2506
5045
  });
2507
5046
 
2508
5047
  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);
5048
+ const { projectBase: cwd } = initFixtureRepo({ fixtures: [] });
2512
5049
  const writeConfig = (overrides: Record<string, unknown>) => {
2513
- const config = {
2514
- ...getDefaultConfig(cwd),
5050
+ writeProjectConfigOverrides(cwd, {
5051
+ GIT_WORKTREE_ENABLED: "disable",
2515
5052
  ...overrides,
2516
- };
2517
- fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8");
5053
+ });
2518
5054
  };
2519
5055
  const pi = createFakePi();
2520
5056
  piUsereqExtension(pi);
@@ -2585,6 +5121,7 @@ test("sound routing honors the selected sound state and per-event toggles", asyn
2585
5121
  assert.equal(recordedCommands.length, 1);
2586
5122
  } finally {
2587
5123
  setPiNotifySpawnForTests(undefined);
5124
+ fs.rmSync(cwd, { recursive: true, force: true });
2588
5125
  }
2589
5126
  });
2590
5127
 
@@ -2624,28 +5161,6 @@ test("sound toggle shortcut cycles persisted pi-notify sound levels", async () =
2624
5161
  }
2625
5162
  });
2626
5163
 
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
5164
  test("static-check exposes the supported programming languages", () => {
2650
5165
  const supported = getSupportedStaticCheckLanguageSupport();
2651
5166
  assert.equal(supported.length, 20);
@@ -2670,8 +5185,15 @@ test("configuration menu omits removed static-check raw-spec and reference actio
2670
5185
  await command!.handler("", ctx);
2671
5186
 
2672
5187
  const renderedStaticCheckMenu = (ctx.__state.customRenderLines[1] ?? []).join("\n");
5188
+ const staticCheckItems = ctx.__state.selectCalls[1]?.items ?? [];
2673
5189
  assert.match(renderedStaticCheckMenu, /Add static code checker/);
2674
5190
  assert.match(renderedStaticCheckMenu, /Remove static code checker/);
5191
+ assert.deepEqual(staticCheckItems.slice(0, 2), ["Add static code checker", "Remove static code checker"]);
5192
+ assert.deepEqual(
5193
+ staticCheckItems.slice(2, 22),
5194
+ getSupportedStaticCheckLanguageSupport().map((entry) => entry.language),
5195
+ );
5196
+ assert.deepEqual(staticCheckItems.slice(-1), ["Reset defaults"]);
2675
5197
  assert.doesNotMatch(renderedStaticCheckMenu, /Add entry from LANG=MODULE\[,CMD\[,PARAM\.\.\.\]\]/);
2676
5198
  assert.doesNotMatch(renderedStaticCheckMenu, /Show supported languages/);
2677
5199
  });
@@ -2696,6 +5218,29 @@ test("configuration menu hides removed static-check modules from user-facing act
2696
5218
  assert.doesNotMatch(renderedStaticCheckMenu, /Dummy/);
2697
5219
  });
2698
5220
 
5221
+ test("configuration menu can toggle per-language static-check enablement", async () => {
5222
+ const cwd = createTempDir("pi-usereq-menu-sc-toggle-");
5223
+ fs.mkdirSync(path.dirname(getProjectConfigPath(cwd)), { recursive: true });
5224
+ const pi = createFakePi();
5225
+ piUsereqExtension(pi);
5226
+ const command = pi.commands.get("pi-usereq");
5227
+ assert.ok(command);
5228
+
5229
+ await command!.handler(
5230
+ "",
5231
+ createFakeCtx(cwd, {
5232
+ selects: ["static-check", "Python", "Save and close", "Save and close"],
5233
+ }),
5234
+ );
5235
+
5236
+ const config = JSON.parse(fs.readFileSync(getProjectConfigPath(cwd), "utf8"));
5237
+ assert.equal(config["static-check"].Python.enabled, "disable");
5238
+ assert.deepEqual(config["static-check"].Python.checkers, [
5239
+ { module: "Command", cmd: "pyright", params: ["--outputjson"] },
5240
+ { module: "Command", cmd: "ruff", params: ["check"] },
5241
+ ]);
5242
+ });
5243
+
2699
5244
  test("configuration menu can add guided static-check entries for explicit supported languages", async () => {
2700
5245
  const cwd = createTempDir("pi-usereq-menu-sc-guided-");
2701
5246
  fs.mkdirSync(path.dirname(getProjectConfigPath(cwd)), { recursive: true });
@@ -2710,7 +5255,7 @@ test("configuration menu can add guided static-check entries for explicit suppor
2710
5255
  selects: [
2711
5256
  "static-check",
2712
5257
  "Add static code checker",
2713
- "Python",
5258
+ "Ruby",
2714
5259
  "Save and close",
2715
5260
  "Save and close",
2716
5261
  ],
@@ -2719,5 +5264,7 @@ test("configuration menu can add guided static-check entries for explicit suppor
2719
5264
  );
2720
5265
 
2721
5266
  const config = JSON.parse(fs.readFileSync(getProjectConfigPath(cwd), "utf8"));
2722
- assert.deepEqual(config["static-check"].Python, [{ module: "Command", cmd: "true" }]);
5267
+ assert.deepEqual(config["static-check"].Ruby, createStaticCheckLanguageConfig([
5268
+ { module: "Command", cmd: "true" },
5269
+ ], "enable"));
2723
5270
  });