headlesscode 1.0.2

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 (232) hide show
  1. package/ATTRIBUTION.md +53 -0
  2. package/CODE_OF_CONDUCT.md +130 -0
  3. package/CONTRIBUTING.md +107 -0
  4. package/LICENSE +202 -0
  5. package/README.md +486 -0
  6. package/SECURITY.md +211 -0
  7. package/bin/headlesscode.mjs +83 -0
  8. package/package.json +63 -0
  9. package/shared/prompts/review-mode-prompt-short.md +93 -0
  10. package/shared/prompts/review-mode-prompt.md +281 -0
  11. package/shared/rules-code/rules.md +22 -0
  12. package/shared/stacks/cpp/rules.md +30 -0
  13. package/shared/stacks/fastapi/rules.md +30 -0
  14. package/shared/stacks/javascript/rules.md +37 -0
  15. package/shared/stacks/postgresql/rules.md +31 -0
  16. package/shared/stacks/python/rules.md +35 -0
  17. package/shared/stacks/react/rules.md +11 -0
  18. package/shared/stacks/typescript/rules.md +10 -0
  19. package/src/budget/budget.ts +221 -0
  20. package/src/budget/concurrency.ts +126 -0
  21. package/src/budget/cost.ts +309 -0
  22. package/src/budget/index.ts +8 -0
  23. package/src/checkpoints/cli.ts +256 -0
  24. package/src/checkpoints/service.ts +227 -0
  25. package/src/cli.ts +1535 -0
  26. package/src/cloud/docker-provider.ts +334 -0
  27. package/src/cloud/provider.ts +300 -0
  28. package/src/codeintel/call-graph.ts +78 -0
  29. package/src/codeintel/find-references.ts +123 -0
  30. package/src/codeintel/go-to-definition.ts +193 -0
  31. package/src/codeintel/handlers.ts +190 -0
  32. package/src/codeintel/import-graph.ts +173 -0
  33. package/src/codeintel/outline.ts +180 -0
  34. package/src/codeintel/position.ts +77 -0
  35. package/src/codeintel/program.ts +350 -0
  36. package/src/codeintel/rename-symbol.ts +213 -0
  37. package/src/codeintel/tools.ts +280 -0
  38. package/src/codemap/build.ts +135 -0
  39. package/src/codemap/cli.ts +190 -0
  40. package/src/codemap/extract.ts +339 -0
  41. package/src/codemap/files.ts +236 -0
  42. package/src/codemap/fingerprint.ts +65 -0
  43. package/src/codemap/flows.ts +62 -0
  44. package/src/codemap/html.ts +451 -0
  45. package/src/codemap/lock.ts +80 -0
  46. package/src/codemap/types.ts +101 -0
  47. package/src/codesearch/airunner-embedder.ts +185 -0
  48. package/src/codesearch/chunk.ts +339 -0
  49. package/src/codesearch/cli.ts +223 -0
  50. package/src/codesearch/embedder.ts +332 -0
  51. package/src/codesearch/files.ts +280 -0
  52. package/src/codesearch/index.ts +469 -0
  53. package/src/codesearch/ollama-embedder.ts +205 -0
  54. package/src/codesearch/search.ts +141 -0
  55. package/src/codesearch/types.ts +100 -0
  56. package/src/config/mode-models.ts +218 -0
  57. package/src/dashboard/aggregate.ts +364 -0
  58. package/src/dashboard/chat-thread.ts +141 -0
  59. package/src/dashboard/checkpoints.ts +124 -0
  60. package/src/dashboard/cli.ts +193 -0
  61. package/src/dashboard/codemap.ts +44 -0
  62. package/src/dashboard/files.ts +121 -0
  63. package/src/dashboard/page.ts +2803 -0
  64. package/src/dashboard/self-improvement-metrics.ts +282 -0
  65. package/src/dashboard/server.ts +1103 -0
  66. package/src/dashboard/session-launch.ts +310 -0
  67. package/src/dashboard/timeline.ts +273 -0
  68. package/src/dashboard/tool-exec.ts +107 -0
  69. package/src/dashboard/trend-cli.ts +141 -0
  70. package/src/dashboard/trend.ts +413 -0
  71. package/src/decision-proxy/cli.ts +261 -0
  72. package/src/decision-proxy/proxy.ts +569 -0
  73. package/src/deploy/gate-cli.ts +147 -0
  74. package/src/deploy/gate.ts +254 -0
  75. package/src/engine/condense.ts +512 -0
  76. package/src/engine/events.ts +428 -0
  77. package/src/engine/handoff.ts +71 -0
  78. package/src/engine/lazy-tools.ts +160 -0
  79. package/src/engine/local-explore.ts +653 -0
  80. package/src/engine/logger.ts +96 -0
  81. package/src/engine/loop.ts +5517 -0
  82. package/src/engine/parser.ts +347 -0
  83. package/src/engine/prompt.ts +860 -0
  84. package/src/engine/reports.ts +47 -0
  85. package/src/engine/stacks.ts +448 -0
  86. package/src/engine/types.ts +291 -0
  87. package/src/engine/usage.ts +186 -0
  88. package/src/github/app-auth.ts +161 -0
  89. package/src/github/cli.ts +448 -0
  90. package/src/github/installations.ts +133 -0
  91. package/src/github/pr.ts +321 -0
  92. package/src/github/provision.ts +118 -0
  93. package/src/github/push.ts +122 -0
  94. package/src/index-util.ts +50 -0
  95. package/src/index.ts +81 -0
  96. package/src/init/cli.ts +248 -0
  97. package/src/init/gitignore.ts +74 -0
  98. package/src/llm/ollama.ts +308 -0
  99. package/src/llm/openrouter.ts +868 -0
  100. package/src/llm/preflight.ts +367 -0
  101. package/src/llm/transcript-capture.ts +84 -0
  102. package/src/memory/embed.ts +110 -0
  103. package/src/memory/index.ts +22 -0
  104. package/src/memory/local.ts +259 -0
  105. package/src/memory/summarizer.ts +283 -0
  106. package/src/memory/types.ts +153 -0
  107. package/src/memory/uwuchat.ts +157 -0
  108. package/src/migrate/cli.ts +115 -0
  109. package/src/orchestrator/analyze-cli.ts +104 -0
  110. package/src/orchestrator/auto-split.ts +206 -0
  111. package/src/orchestrator/cleanup.ts +1003 -0
  112. package/src/orchestrator/cli.ts +3571 -0
  113. package/src/orchestrator/cost-estimate.ts +564 -0
  114. package/src/orchestrator/cost-history-cli.ts +242 -0
  115. package/src/orchestrator/cost-history.ts +397 -0
  116. package/src/orchestrator/git-sync.ts +250 -0
  117. package/src/orchestrator/index.ts +153 -0
  118. package/src/orchestrator/log-analysis.ts +0 -0
  119. package/src/orchestrator/merge-check.ts +108 -0
  120. package/src/orchestrator/pipeline.ts +411 -0
  121. package/src/orchestrator/resume.ts +1940 -0
  122. package/src/orchestrator/reviewer.ts +503 -0
  123. package/src/orchestrator/split.ts +296 -0
  124. package/src/orchestrator/state.ts +542 -0
  125. package/src/orchestrator/status.ts +697 -0
  126. package/src/orchestrator/verification-gate.ts +134 -0
  127. package/src/orchestrator/watch.ts +898 -0
  128. package/src/permissions/commands.ts +1083 -0
  129. package/src/permissions/config.ts +241 -0
  130. package/src/permissions/index.ts +12 -0
  131. package/src/permissions/protected-files.ts +96 -0
  132. package/src/permissions/store-protection.ts +272 -0
  133. package/src/project-store.ts +648 -0
  134. package/src/projects/cli.ts +382 -0
  135. package/src/qa/qa.ts +487 -0
  136. package/src/tools/browser/handler.ts +346 -0
  137. package/src/tools/browser/service.ts +406 -0
  138. package/src/tools/browser/smoke.ts +78 -0
  139. package/src/tools/browser/tool.ts +99 -0
  140. package/src/tools/executor.ts +2575 -0
  141. package/src/tools/language-detect.ts +183 -0
  142. package/src/tools/output-summarizer.ts +369 -0
  143. package/src/tools/run-tests.ts +302 -0
  144. package/src/tools/set-indentation-tool.ts +49 -0
  145. package/src/tools/test-selection.ts +160 -0
  146. package/src/vendor/tests/smoke.ts +103 -0
  147. package/src/vendor/zoo-code/VENDOR-NOTES.md +213 -0
  148. package/src/vendor/zoo-code/shim/anthropic.ts +71 -0
  149. package/src/vendor/zoo-code/shim/openai.d.ts +60 -0
  150. package/src/vendor/zoo-code/shim/os-name.ts +18 -0
  151. package/src/vendor/zoo-code/shim/strip-bom.ts +14 -0
  152. package/src/vendor/zoo-code/shim/vscode.ts +76 -0
  153. package/src/vendor/zoo-code/src/core/config/CustomModesManager.ts +1015 -0
  154. package/src/vendor/zoo-code/src/core/diff/strategies/multi-search-replace.ts +670 -0
  155. package/src/vendor/zoo-code/src/core/prompts/sections/capabilities.ts +46 -0
  156. package/src/vendor/zoo-code/src/core/prompts/sections/custom-instructions.ts +559 -0
  157. package/src/vendor/zoo-code/src/core/prompts/sections/index.ts +10 -0
  158. package/src/vendor/zoo-code/src/core/prompts/sections/markdown-formatting.ts +7 -0
  159. package/src/vendor/zoo-code/src/core/prompts/sections/modes.ts +35 -0
  160. package/src/vendor/zoo-code/src/core/prompts/sections/objective.ts +13 -0
  161. package/src/vendor/zoo-code/src/core/prompts/sections/rules.ts +95 -0
  162. package/src/vendor/zoo-code/src/core/prompts/sections/skills.ts +105 -0
  163. package/src/vendor/zoo-code/src/core/prompts/sections/system-info.ts +30 -0
  164. package/src/vendor/zoo-code/src/core/prompts/sections/tool-use-guidelines.ts +9 -0
  165. package/src/vendor/zoo-code/src/core/prompts/sections/tool-use.ts +7 -0
  166. package/src/vendor/zoo-code/src/core/prompts/system.ts +176 -0
  167. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/access_mcp_resource.ts +41 -0
  168. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/apply_diff.ts +40 -0
  169. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/apply_patch.ts +61 -0
  170. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/ask_followup_question.ts +62 -0
  171. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/attempt_completion.ts +33 -0
  172. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/codebase_search.ts +43 -0
  173. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/converters.ts +109 -0
  174. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/edit.ts +48 -0
  175. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/edit_file.ts +72 -0
  176. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/execute_command.ts +54 -0
  177. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/generate_image.ts +51 -0
  178. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/index.ts +75 -0
  179. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/list_files.ts +41 -0
  180. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/mcp_server.ts +75 -0
  181. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/new_task.ts +39 -0
  182. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/read_command_output.ts +81 -0
  183. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/read_file.ts +169 -0
  184. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/run_slash_command.ts +31 -0
  185. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/search_files.ts +50 -0
  186. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/search_replace.ts +51 -0
  187. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/skill.ts +33 -0
  188. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/switch_mode.ts +31 -0
  189. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/update_todo_list.ts +54 -0
  190. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/write_to_file.ts +40 -0
  191. package/src/vendor/zoo-code/src/core/prompts/types.ts +12 -0
  192. package/src/vendor/zoo-code/src/i18n/index.ts +19 -0
  193. package/src/vendor/zoo-code/src/integrations/misc/extract-text.ts +81 -0
  194. package/src/vendor/zoo-code/src/services/checkpoints/RepoPerTaskCheckpointService.ts +15 -0
  195. package/src/vendor/zoo-code/src/services/checkpoints/ShadowCheckpointService.ts +553 -0
  196. package/src/vendor/zoo-code/src/services/checkpoints/excludes.ts +212 -0
  197. package/src/vendor/zoo-code/src/services/checkpoints/index.ts +3 -0
  198. package/src/vendor/zoo-code/src/services/checkpoints/types.ts +35 -0
  199. package/src/vendor/zoo-code/src/services/code-index/manager.ts +19 -0
  200. package/src/vendor/zoo-code/src/services/mcp/McpHub.ts +36 -0
  201. package/src/vendor/zoo-code/src/services/roo-config/index.ts +441 -0
  202. package/src/vendor/zoo-code/src/services/search/file-search.ts +143 -0
  203. package/src/vendor/zoo-code/src/services/skills/SkillsManager.ts +20 -0
  204. package/src/vendor/zoo-code/src/shared/globalFileNames.ts +9 -0
  205. package/src/vendor/zoo-code/src/shared/language.ts +43 -0
  206. package/src/vendor/zoo-code/src/shared/modes.ts +257 -0
  207. package/src/vendor/zoo-code/src/shared/tools.ts +385 -0
  208. package/src/vendor/zoo-code/src/utils/fs.ts +39 -0
  209. package/src/vendor/zoo-code/src/utils/globalContext.ts +22 -0
  210. package/src/vendor/zoo-code/src/utils/json-schema.ts +16 -0
  211. package/src/vendor/zoo-code/src/utils/logging.ts +21 -0
  212. package/src/vendor/zoo-code/src/utils/mcp-name.ts +190 -0
  213. package/src/vendor/zoo-code/src/utils/object.ts +18 -0
  214. package/src/vendor/zoo-code/src/utils/path.ts +94 -0
  215. package/src/vendor/zoo-code/src/utils/shell.ts +376 -0
  216. package/src/vendor/zoo-code/src/utils/text-normalization.ts +99 -0
  217. package/src/vendor/zoo-code/types/global-settings.ts +19 -0
  218. package/src/vendor/zoo-code/types/index.ts +22 -0
  219. package/src/vendor/zoo-code/types/message.ts +375 -0
  220. package/src/vendor/zoo-code/types/mode.ts +241 -0
  221. package/src/vendor/zoo-code/types/todo.ts +19 -0
  222. package/src/vendor/zoo-code/types/tool-params.ts +116 -0
  223. package/src/vendor/zoo-code/types/tool.ts +67 -0
  224. package/src/vendor/zoo-code/types/vscode.ts +84 -0
  225. package/src/vision/describe.ts +242 -0
  226. package/src/vision/tool.ts +91 -0
  227. package/src/watcher/cli.ts +369 -0
  228. package/src/watcher/github.ts +304 -0
  229. package/src/watcher/index.ts +59 -0
  230. package/src/watcher/state.ts +254 -0
  231. package/src/watcher/watch.ts +562 -0
  232. package/tsconfig.json +18 -0
@@ -0,0 +1,302 @@
1
+ /**
2
+ * `run_tests` — run the SPECIFIC test file(s) relevant to a set of changed
3
+ * files, instead of always paying the full `npm test` cost (~70 files,
4
+ * measured 111s live) during the iterative edit-check-edit loop.
5
+ *
6
+ * Selection is the heuristic in src/tools/test-selection.ts (direct
7
+ * `src/foo/bar.ts` → `src/foo/__tests__/bar.test.ts` match, then reverse
8
+ * dependency via the REAL import graph — the same resolution the
9
+ * `import_graph` tool uses), and the matched files run through the SAME
10
+ * `tsx` invocation `npm test` uses per-file, sequentially, stopping at the
11
+ * first failure exactly like `npm test`'s `&&` chain.
12
+ *
13
+ * SECURITY: the selected test paths come from an untrusted workspace (they
14
+ * are file names that can contain shell metacharacters), so they are passed
15
+ * to `spawn` as ARGV with `shell: false` — never interpolated into a shell
16
+ * command string. See issue #62 (the old `shellQuote` allowed command
17
+ * substitution via `$()`/backticks in a hostile test file name).
18
+ *
19
+ * IMPORTANT (this is the tool's whole point, stated to the model): a
20
+ * passing selective run is NOT full-suite green. The FULL `npm test` is
21
+ * still what gates a merge/PR and is still required once before
22
+ * `attempt_completion`. This tool exists to make the per-edit loop cheap,
23
+ * not to replace the final confidence run.
24
+ */
25
+
26
+ import { execFileSync, spawn } from "node:child_process"
27
+ import type OpenAI from "openai"
28
+
29
+ import type { ToolContext, ToolResult } from "../engine/types.js"
30
+ import { getCodeIntelCache } from "../codeintel/program.js"
31
+ import { selectTestsForChangedFiles } from "./test-selection.js"
32
+
33
+ export const RUN_TESTS_NAME = "run_tests"
34
+
35
+ /** Default per-test-file timeout, seconds (a single tsx-invoked suite). */
36
+ const DEFAULT_TEST_TIMEOUT_S = 180
37
+
38
+ const RUN_TESTS_DESCRIPTION = `Run the SPECIFIC test file(s) relevant to one or more changed files, instead of always paying the full npm test cost during the iterative edit-check-edit loop. Selection is automatic: for each changed file it looks for a direct test (src/foo/bar.ts → src/foo/__tests__/bar.test.ts), then for test files that import the changed file (via the real import graph, 1-2 hops). Matched files run through the same tsx invocation npm test uses per-file, sequentially, stopping at the first failure.
39
+
40
+ Use this after editing a file to quickly verify you didn't break its tests — NOT as a replacement for the full suite. A passing selective run does NOT prove the whole project is green: run the full npm test once right before attempt_completion.
41
+
42
+ When no specific test matches (e.g. you changed package.json or a script), the tool reports "no specific tests matched — consider running the full suite" honestly rather than silently running nothing.
43
+
44
+ Parameters:
45
+ - paths: (optional) The changed file path(s), relative to the workspace root. Omit to infer from the session's baseline checkpoint (or the workspace's git status when no checkpoint service is active).
46
+ - timeout: (optional) Per-test-file timeout in seconds (default 180).
47
+
48
+ Example: { "paths": ["src/tools/executor.ts"], "timeout": 120 }`
49
+
50
+ const RT_PATHS_PARAMETER_DESCRIPTION = `Changed file path(s), relative to the workspace root. Omit to infer from the session's baseline checkpoint (or the workspace's git status).`
51
+ const RT_TIMEOUT_PARAMETER_DESCRIPTION = `Per-test-file timeout in seconds (default 180).`
52
+
53
+ export const runTestsTool = {
54
+ type: "function",
55
+ function: {
56
+ name: RUN_TESTS_NAME,
57
+ description: RUN_TESTS_DESCRIPTION,
58
+ // Note: strict mode is intentionally disabled for this tool (mirrors
59
+ // read_command_output.ts's precedent). With strict: true, every
60
+ // property must be in `required`, which forces nullable-union types
61
+ // (`type: ["array", "null"]`) for genuinely optional params so the
62
+ // model can omit them — DeepSeek's OpenRouter endpoint rejects that
63
+ // union-type syntax outright ("unknown variant `array`, expected one
64
+ // of `string`, `number`, `integer`, `boolean`, `null`"), breaking
65
+ // EVERY session's very first request since the tool list itself is
66
+ // sent up front. Plain, non-strict optional properties avoid this
67
+ // entirely and match how ask_followup_question's `follow_up`
68
+ // (required) array param is already declared elsewhere.
69
+ parameters: {
70
+ type: "object",
71
+ properties: {
72
+ paths: {
73
+ type: "array",
74
+ items: { type: "string" },
75
+ description: RT_PATHS_PARAMETER_DESCRIPTION,
76
+ },
77
+ timeout: {
78
+ type: "integer",
79
+ description: RT_TIMEOUT_PARAMETER_DESCRIPTION,
80
+ },
81
+ },
82
+ additionalProperties: false,
83
+ },
84
+ },
85
+ } satisfies OpenAI.Chat.ChatCompletionTool
86
+
87
+ /** Infer changed files from the workspace's own git status (fallback). */
88
+ function changedFilesFromGit(workspaceRoot: string): string[] {
89
+ try {
90
+ const out = execFileSync("git", ["status", "--short", "--untracked-files=all"], {
91
+ cwd: workspaceRoot,
92
+ encoding: "utf-8",
93
+ timeout: 15_000,
94
+ })
95
+ const files: string[] = []
96
+ for (const line of out.split("\n")) {
97
+ const trimmed = line.trim()
98
+ if (trimmed === "") {
99
+ continue
100
+ }
101
+ // Format: "<XY> <path>" (2 status chars + space); renames are
102
+ // "R old -> new" — the current path is the rename target.
103
+ const m = trimmed.match(/^.{1,2}\s+(.+)$/)
104
+ if (m === null) {
105
+ continue
106
+ }
107
+ let p = m[1]!.trim()
108
+ const arrow = p.indexOf(" -> ")
109
+ if (arrow !== -1) {
110
+ p = p.slice(arrow + 4)
111
+ }
112
+ files.push(p)
113
+ }
114
+ return files
115
+ } catch {
116
+ return []
117
+ }
118
+ }
119
+
120
+ function ok(content: string): ToolResult {
121
+ return { content, isError: false }
122
+ }
123
+
124
+ function err(content: string): ToolResult {
125
+ return { content: `[Error] ${content}`, isError: true }
126
+ }
127
+
128
+ /** Cap on the combined test output fed back to the model. */
129
+ const MAX_TEST_OUTPUT_CHARS = 30_000
130
+
131
+ function truncateOutput(text: string): { text: string; truncated: boolean } {
132
+ if (text.length <= MAX_TEST_OUTPUT_CHARS) {
133
+ return { text, truncated: false }
134
+ }
135
+ return { text: `${text.slice(0, MAX_TEST_OUTPUT_CHARS)}\n…[output truncated]`, truncated: true }
136
+ }
137
+
138
+ interface OneFileResult {
139
+ rel: string
140
+ exitCode: number | null
141
+ output: string
142
+ timedOut: boolean
143
+ }
144
+
145
+ /**
146
+ * Run one test file via `npx --no-install tsx <file>` (the npm-test
147
+ * invocation). The test path is treated as UNTRUSTED input (it comes from a
148
+ * workspace's file names): it is passed as a separate argv element with
149
+ * `shell: false`, so no shell metacharacter in it — `$()`, backticks, `;`,
150
+ * `&`, spaces, quotes — can ever be interpreted (issue #62). `npx
151
+ * --no-install` avoids any network fetch if the local tsx is missing.
152
+ */
153
+ export function runOneTestFile(workspaceRoot: string, rel: string, timeoutS: number): Promise<OneFileResult> {
154
+ return new Promise((resolve) => {
155
+ let stdout = ""
156
+ let stderr = ""
157
+ let settled = false
158
+ let timedOut = false
159
+
160
+ const finish = (exitCode: number | null): void => {
161
+ if (!settled) {
162
+ settled = true
163
+ resolve({ rel, exitCode, output: `${stdout}${stderr}`, timedOut })
164
+ }
165
+ }
166
+
167
+ let child
168
+ try {
169
+ child = spawn("npx", ["--no-install", "tsx", rel], {
170
+ cwd: workspaceRoot,
171
+ // `shell: false` (default) — argv is passed verbatim, never
172
+ // re-parsed by a shell. This is the actual security boundary.
173
+ shell: false,
174
+ stdio: ["ignore", "pipe", "pipe"],
175
+ env: { ...process.env, LANG: "en_US.UTF-8", LC_ALL: "en_US.UTF-8" },
176
+ })
177
+ } catch (error) {
178
+ resolve({ rel, exitCode: null, output: `spawn error: ${error instanceof Error ? error.message : String(error)}`, timedOut: false })
179
+ return
180
+ }
181
+
182
+ const timer = setTimeout(() => {
183
+ timedOut = true
184
+ child.kill("SIGKILL")
185
+ }, timeoutS * 1000)
186
+
187
+ child.stdout?.on("data", (chunk: Buffer) => {
188
+ stdout += chunk.toString()
189
+ })
190
+ child.stderr?.on("data", (chunk: Buffer) => {
191
+ stderr += chunk.toString()
192
+ })
193
+ child.on("error", (error) => {
194
+ clearTimeout(timer)
195
+ stdout += `spawn error: ${error.message}\n`
196
+ finish(null)
197
+ })
198
+ child.on("close", (code) => {
199
+ clearTimeout(timer)
200
+ finish(code)
201
+ })
202
+ })
203
+ }
204
+
205
+ /**
206
+ * Run the `run_tests` tool. `getSessionChangedFiles` is the session-provided
207
+ * inference hook (checkpoint-diff based — see HeadlessSession); when absent
208
+ * or returning undefined the handler falls back to the workspace git status.
209
+ */
210
+ export async function runTestsHandler(
211
+ args: Record<string, unknown>,
212
+ ctx: ToolContext,
213
+ getSessionChangedFiles?: () => Promise<string[] | undefined>,
214
+ ): Promise<ToolResult> {
215
+ const rawPaths = args.paths
216
+ const timeoutS =
217
+ typeof args.timeout === "number" && args.timeout > 0
218
+ ? args.timeout
219
+ : typeof args.timeout === "string" && Number(args.timeout) > 0
220
+ ? Number(args.timeout)
221
+ : DEFAULT_TEST_TIMEOUT_S
222
+
223
+ let changedFiles: string[] | undefined
224
+ if (Array.isArray(rawPaths) && rawPaths.length > 0) {
225
+ changedFiles = rawPaths
226
+ .filter((p): p is string => typeof p === "string" && p.trim() !== "")
227
+ .map((p) => p.trim())
228
+ if (changedFiles.length === 0) {
229
+ return err("run_tests: 'paths' must be a non-empty array of file path strings")
230
+ }
231
+ }
232
+
233
+ if (changedFiles === undefined) {
234
+ // Session-baseline inference first (checkpoint diff), then git status.
235
+ try {
236
+ changedFiles = (await getSessionChangedFiles?.()) ?? changedFilesFromGit(ctx.workspaceRoot)
237
+ } catch {
238
+ changedFiles = changedFilesFromGit(ctx.workspaceRoot)
239
+ }
240
+ }
241
+ if (changedFiles === undefined || changedFiles.length === 0) {
242
+ return err(
243
+ "run_tests: no changed files could be determined (no 'paths' given, no session baseline, and the workspace has no git status to read). Pass explicit 'paths', or run the full suite (npm test).",
244
+ )
245
+ }
246
+
247
+ const intel = getCodeIntelCache(ctx.workspaceRoot).get()
248
+ const { tests, notes } = selectTestsForChangedFiles(intel, ctx.workspaceRoot, changedFiles)
249
+
250
+ if (tests.length === 0) {
251
+ // Fail-safe: never report a silent pass of zero tests.
252
+ return err(
253
+ `run_tests: no specific tests matched for the changed file(s). ${notes.join("; ")} — consider running the full suite (npm test) instead of treating this as a pass.`,
254
+ )
255
+ }
256
+
257
+ const header = `run_tests: ${tests.length} test file(s) matched for ${changedFiles.length} changed file(s):\n${tests
258
+ .map((t) => ` ${t}`)
259
+ .join("\n")}`
260
+ const noteLine = notes.length > 0 ? `Selection notes:\n${notes.map((n) => ` - ${n}`).join("\n")}` : ""
261
+
262
+ // Run each file sequentially, stopping at the first failure (mirrors
263
+ // `npm test`'s `&&` chain). Failures and passes are both reported with
264
+ // their real output — the model must not mistake a selective pass for a
265
+ // full-suite green (the trailer says so explicitly).
266
+ const blocks: string[] = [header, noteLine, ""]
267
+ for (const rel of tests) {
268
+ // Defense-in-depth (the argv spawn in runOneTestFile is the primary
269
+ // boundary): reject paths containing newlines outright — they cannot
270
+ // be legitimate test files, and they would let a workspace smuggle
271
+ // forged "test result" content (a fake pass/fail line) into the tool
272
+ // result. Nothing is run for such a path.
273
+ if (/\r?\n/.test(rel)) {
274
+ blocks.push(`── ${rel} ─ (skipped: path contains a newline — not a legitimate test file)`)
275
+ blocks.push("[run_tests] FAILED: test path contains a newline (potential output-injection attempt)")
276
+ return err(blocks.join("\n"))
277
+ }
278
+ const result = await runOneTestFile(ctx.workspaceRoot, rel, timeoutS)
279
+ const { text: output, truncated } = truncateOutput(result.output)
280
+ blocks.push(`── ${rel} ─${truncated ? " (output truncated)" : ""}`)
281
+ blocks.push(output.trim() === "" ? "(no output)" : output)
282
+ if (result.timedOut) {
283
+ blocks.push(`[run_tests] ${rel} timed out after ${timeoutS}s (killed)`)
284
+ return err(blocks.join("\n"))
285
+ }
286
+ if (result.exitCode !== 0) {
287
+ blocks.push(`[run_tests] FAILED at ${rel} (exit ${result.exitCode ?? "spawn error"}) — remaining files not run (same as npm test's && chain)`)
288
+ return err(blocks.join("\n"))
289
+ }
290
+ blocks.push(`[run_tests] ${rel} exited 0`)
291
+ }
292
+
293
+ blocks.push(
294
+ "",
295
+ "[run_tests] all matched test file(s) passed.",
296
+ "NOTE: a passing selective run is NOT full-suite green — run the full `npm test` once before attempt_completion.",
297
+ )
298
+ return ok(blocks.join("\n"))
299
+ }
300
+
301
+ /** Exported for tests: the pure git-status fallback inference. */
302
+ export { changedFilesFromGit }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * `set_indentation` schema (issue #141) — sets ONE line's leading
3
+ * indentation to an exact tab count. The handler lives in executor.ts
4
+ * (setIndentationHandler) alongside edit_file/write_to_file, since it needs
5
+ * the same private path-safety/protected-file helpers those tools already
6
+ * use; this file only holds the schema, mirroring run-tests.ts's split.
7
+ *
8
+ * All 3 parameters are required — deliberately, unlike run_tests's optional
9
+ * paths/timeout: a tool with any optional parameter hits a known
10
+ * llama.cpp/llama-cpp-python grammar-constrained-decoding bug that corrupts
11
+ * structured tool calls (see prompt.ts's patchEditFileToolForLocalModels
12
+ * doc comment). Keeping every parameter required sidesteps that bug
13
+ * category entirely, on top of this tool's main point — see executor.ts's
14
+ * setIndentationHandler doc comment for the real motivation (a local model
15
+ * struggling to compose two multi-line strings differing only in tab
16
+ * count, not a matching-strictness problem).
17
+ */
18
+
19
+ import type OpenAI from "openai"
20
+
21
+ export const SET_INDENTATION_NAME = "set_indentation"
22
+
23
+ const SET_INDENTATION_DESCRIPTION = `Change ONE line's leading indentation to an exact number of tabs, without touching the rest of the line or any other line. Use this for a pure indentation/whitespace-only fix instead of edit_file — edit_file requires typing out the full line twice (once in old_string, once in new_string) differing only in leading whitespace, which is easy to get subtly wrong. This tool takes a plain line number and a plain tab count instead.
24
+
25
+ Only fixes indentation made of TABS. If the file uses space-based indentation, use edit_file instead.
26
+
27
+ Example: { "path": "src/foo.ts", "line": 42, "tabs": 3 } sets line 42's leading whitespace to exactly 3 tabs (\\t\\t\\t), replacing however many tabs/spaces were there before.`
28
+
29
+ const SI_PATH_PARAMETER_DESCRIPTION = `File path, relative to the workspace root.`
30
+ const SI_LINE_PARAMETER_DESCRIPTION = `1-indexed line number to change. Use read_file first to confirm it.`
31
+ const SI_TABS_PARAMETER_DESCRIPTION = `Exact number of leading tab characters the line should have after this call (0 removes all leading indentation).`
32
+
33
+ export const setIndentationTool = {
34
+ type: "function",
35
+ function: {
36
+ name: SET_INDENTATION_NAME,
37
+ description: SET_INDENTATION_DESCRIPTION,
38
+ parameters: {
39
+ type: "object",
40
+ properties: {
41
+ path: { type: "string", description: SI_PATH_PARAMETER_DESCRIPTION },
42
+ line: { type: "integer", description: SI_LINE_PARAMETER_DESCRIPTION },
43
+ tabs: { type: "integer", description: SI_TABS_PARAMETER_DESCRIPTION },
44
+ },
45
+ required: ["path", "line", "tabs"],
46
+ additionalProperties: false,
47
+ },
48
+ },
49
+ } satisfies OpenAI.Chat.ChatCompletionTool
@@ -0,0 +1,160 @@
1
+ /**
2
+ * Test-selection heuristic for the `run_tests` tool.
3
+ *
4
+ * Deliberately simple — the goal is "much better than always running the
5
+ * full suite during the iterative edit-check-edit loop", not a perfect
6
+ * solver:
7
+ *
8
+ * 1. Direct match: `src/foo/bar.ts` changed → `src/foo/__tests__/bar.test.ts`
9
+ * if it exists (this repo's test-file convention). A changed file that
10
+ * IS itself a test runs itself.
11
+ * 2. Reverse-dependency match: reuse the REAL import graph
12
+ * (src/codeintel/import-graph.ts — the same resolution the `import_graph`
13
+ * tool uses, correct on `paths` aliases and extension resolution) to find
14
+ * which files import the changed file — directly (1 hop) or through one
15
+ * intermediate module (2 hops, the common "lib → helper → test" case).
16
+ * Any importer that is a test file is selected.
17
+ * 3. No match → the caller reports "no specific tests matched — consider
18
+ * running the full suite" rather than silently running nothing.
19
+ *
20
+ * A source file's own direct test wins over reverse-dependency matches for
21
+ * determinism; everything is deduped and sorted for stable output.
22
+ */
23
+
24
+ import * as fs from "node:fs"
25
+ import * as path from "node:path"
26
+
27
+ import { getImportGraph } from "../codeintel/import-graph.js"
28
+ import type { CodeIntel } from "../codeintel/program.js"
29
+
30
+ /** True for `foo.test.ts` / `foo.spec.tsx` style names, or any file inside a `__tests__` dir. */
31
+ export function isTestFile(relPath: string): boolean {
32
+ const normalized = relPath.split(path.sep).join("/")
33
+ if (/(^|\/)__tests__(\/|$)/.test(normalized)) {
34
+ return true
35
+ }
36
+ return /[^/]+\.(test|spec)\.(m|c)?[tj]sx?$/i.test(normalized)
37
+ }
38
+
39
+ /**
40
+ * Direct match for a changed source file: `src/foo/bar.ts` →
41
+ * `src/foo/__tests__/bar.test.ts`. Only applies to source extensions the
42
+ * project can test (TS/JS); returns undefined for everything else (scripts,
43
+ * config, docs — those have no per-file test to run directly).
44
+ */
45
+ export function directTestMatch(relSource: string): string | undefined {
46
+ const normalized = relSource.split(path.sep).join("/")
47
+ if (!/\.(m|c)?[tj]sx?$/i.test(normalized)) {
48
+ return undefined
49
+ }
50
+ const dir = path.posix.dirname(normalized)
51
+ const base = path.posix.basename(normalized).replace(/\.(m|c)?[tj]sx?$/i, "")
52
+ // Never map a test file onto a sibling test (it already matched itself).
53
+ if (isTestFile(normalized)) {
54
+ return undefined
55
+ }
56
+ return `${dir}/__tests__/${base}.test.ts`
57
+ }
58
+
59
+ /**
60
+ * Reverse-dependency match: every TEST file that (transitively, up to
61
+ * `maxDepth` hops) imports the changed file, per the real import graph.
62
+ * `absTarget` is the changed file's absolute path.
63
+ */
64
+ export function reverseDependencyTests(
65
+ intel: CodeIntel,
66
+ workspaceRoot: string,
67
+ absTarget: string,
68
+ maxDepth = 2,
69
+ ): string[] {
70
+ const graph = getImportGraph(intel)
71
+ const found: string[] = []
72
+ const seenFiles = new Set<string>([absTarget])
73
+
74
+ // BFS from the changed file along reverse import edges (`importedBy`).
75
+ let frontier = [absTarget]
76
+ for (let depth = 0; depth < maxDepth && frontier.length > 0; depth++) {
77
+ const next: string[] = []
78
+ for (const file of frontier) {
79
+ for (const importer of graph.importedBy.get(file) ?? []) {
80
+ if (seenFiles.has(importer)) {
81
+ continue
82
+ }
83
+ seenFiles.add(importer)
84
+ const rel = path.relative(workspaceRoot, importer).split(path.sep).join("/")
85
+ if (isTestFile(rel)) {
86
+ found.push(rel)
87
+ }
88
+ next.push(importer)
89
+ }
90
+ }
91
+ frontier = next
92
+ }
93
+ return found
94
+ }
95
+
96
+ export interface TestSelectionResult {
97
+ /** Selected test files, workspace-relative posix paths, sorted + deduped. */
98
+ tests: string[]
99
+ /** Human-readable notes on WHY each file was selected (for the tool result). */
100
+ notes: string[]
101
+ }
102
+
103
+ /**
104
+ * Select the test file(s) to run for a set of changed workspace-relative
105
+ * files. Returns an empty `tests` list (with a note) when nothing matches —
106
+ * the caller must report the fail-safe "no specific tests matched" instead
107
+ * of pretending zero tests ran.
108
+ */
109
+ export function selectTestsForChangedFiles(
110
+ intel: CodeIntel,
111
+ workspaceRoot: string,
112
+ changedFiles: string[],
113
+ ): TestSelectionResult {
114
+ const tests = new Set<string>()
115
+ const notes: string[] = []
116
+ const seenNotes = new Set<string>()
117
+
118
+ const note = (text: string): void => {
119
+ if (!seenNotes.has(text)) {
120
+ seenNotes.add(text)
121
+ notes.push(text)
122
+ }
123
+ }
124
+
125
+ for (const rel of changedFiles) {
126
+ const normalized = rel.split(path.sep).join("/")
127
+ if (isTestFile(normalized)) {
128
+ tests.add(normalized)
129
+ note(`direct: ${normalized} is itself a test file`)
130
+ continue
131
+ }
132
+ const direct = directTestMatch(normalized)
133
+ if (direct !== undefined && fileExists(path.join(workspaceRoot, direct))) {
134
+ tests.add(direct)
135
+ note(`direct: ${normalized} changed → ${direct}`)
136
+ continue
137
+ }
138
+ const abs = path.resolve(workspaceRoot, normalized)
139
+ const reverse = reverseDependencyTests(intel, workspaceRoot, abs)
140
+ if (reverse.length > 0) {
141
+ for (const t of reverse) {
142
+ tests.add(t)
143
+ }
144
+ note(`reverse-dependency: ${normalized} is imported by ${reverse.join(", ")}`)
145
+ continue
146
+ }
147
+ note(`no test matched ${normalized} (no direct test, no test imports it)`)
148
+ }
149
+
150
+ const sorted = [...tests].sort()
151
+ return { tests: sorted, notes }
152
+ }
153
+
154
+ function fileExists(p: string): boolean {
155
+ try {
156
+ return fs.statSync(p).isFile()
157
+ } catch {
158
+ return false
159
+ }
160
+ }
@@ -0,0 +1,103 @@
1
+ /**
2
+ * THROWAWAY SMOKE TEST — proves the vendored prompt builder can be imported
3
+ * and called headlessly (no VS Code), and that the native tool schemas assemble.
4
+ *
5
+ * Run with: npm run smoke (i.e. tsx src/vendor/tests/smoke.ts)
6
+ */
7
+
8
+ import type { ExtensionContext } from "../zoo-code/shim/vscode.js"
9
+
10
+ // Side effect: installs String.prototype.toPosix() used by prompt sections.
11
+ import "../zoo-code/src/utils/path.js"
12
+
13
+ import { SYSTEM_PROMPT } from "../zoo-code/src/core/prompts/system.js"
14
+ import {
15
+ getNativeTools,
16
+ convertOpenAIToolsToAnthropic,
17
+ } from "../zoo-code/src/core/prompts/tools/native-tools/index.js"
18
+ import { customModesSettingsSchema } from "../zoo-code/types/index.js"
19
+
20
+ async function main(): Promise<void> {
21
+ // Minimal ExtensionContext stand-in (the vendored builder only reads
22
+ // globalState.get("customModes"/"customModePrompts") when building MODES).
23
+ const context = {
24
+ globalState: {
25
+ get: async () => undefined,
26
+ update: async () => {},
27
+ },
28
+ globalStorageUri: { fsPath: process.cwd() },
29
+ subscriptions: [],
30
+ } as unknown as ExtensionContext
31
+
32
+ // 1. Build a system prompt for the built-in "code" mode, with custom
33
+ // instructions text spliced in (the acceptance-criteria shape).
34
+ const customInstructions = "This is throwaway smoke-test global instructions text."
35
+ const prompt = await SYSTEM_PROMPT(
36
+ context,
37
+ process.cwd(),
38
+ false,
39
+ undefined,
40
+ undefined,
41
+ "code",
42
+ undefined,
43
+ undefined,
44
+ customInstructions,
45
+ )
46
+
47
+ for (const needle of [
48
+ "TOOL USE",
49
+ "OBJECTIVE",
50
+ "RULES",
51
+ "MODES",
52
+ "CAPABILITIES",
53
+ "SYSTEM INFORMATION",
54
+ "You are Zoo, a highly skilled software engineer",
55
+ "USER'S CUSTOM INSTRUCTIONS",
56
+ `Global Instructions:\n${customInstructions}`,
57
+ ]) {
58
+ if (!prompt.includes(needle)) {
59
+ throw new Error(`System prompt missing expected section: "${needle}"`)
60
+ }
61
+ }
62
+
63
+ // 2. Assemble the native (OpenAI-format) tool schemas.
64
+ const tools = getNativeTools()
65
+ if (tools.length < 20) {
66
+ throw new Error(`Expected >= 20 native tools, got ${tools.length}`)
67
+ }
68
+ const names = tools.map((t) => (t.type === "function" ? t.function.name : t.type))
69
+ if (!names.includes("read_file") || !names.includes("execute_command") || !names.includes("write_to_file")) {
70
+ throw new Error(`Native tools missing core tools: ${names.join(", ")}`)
71
+ }
72
+
73
+ // 3. Convert to Anthropic format.
74
+ const anthropicTools = convertOpenAIToolsToAnthropic(tools)
75
+ if (anthropicTools.length !== tools.length) {
76
+ throw new Error("Anthropic conversion count mismatch")
77
+ }
78
+
79
+ // 4. The .roomodes zod schema parses a minimal customModes YAML document.
80
+ const parsed = customModesSettingsSchema.safeParse({
81
+ customModes: [
82
+ {
83
+ slug: "smoke",
84
+ name: "Smoke",
85
+ roleDefinition: "You are a smoke test mode.",
86
+ groups: ["read"],
87
+ },
88
+ ],
89
+ })
90
+ if (!parsed.success) {
91
+ throw new Error(`customModesSettingsSchema rejected a valid config: ${JSON.stringify(parsed.error)}`)
92
+ }
93
+
94
+ console.log(
95
+ `[smoke] system prompt built (${prompt.length} chars); native tools: ${tools.length}; anthropic tools: ${anthropicTools.length}; roomodes schema: ok`,
96
+ )
97
+ console.log("[smoke] OK")
98
+ }
99
+
100
+ main().catch((err) => {
101
+ console.error("[smoke] FAILED:", err)
102
+ process.exit(1)
103
+ })