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,321 @@
1
+ /**
2
+ * Open a pull request via the GitHub REST API using the installation token.
3
+ *
4
+ * `openPullRequest()` is the PR half of push-back: after `pushBranch()` has
5
+ * pushed a named branch (see src/github/push.ts), this opens a PR from that
6
+ * `head` branch into a `base` branch.
7
+ *
8
+ * `getPullRequestStatus()` is the read-back half used by orchestrator
9
+ * worktree cleanup (`src/orchestrator/merge-check.ts`): a squash/rebase PR
10
+ * merge creates a NEW commit whose hash is nothing on the source branch, so
11
+ * the local `merge-base --is-ancestor` check alone would wrongly report
12
+ * "not merged". GitHub's server-computed `.merged` boolean on
13
+ * `GET /repos/{owner}/{repo}/pulls/{number}` is the only authoritative
14
+ * "is this PR merged" answer for that workflow.
15
+ *
16
+ * The base branch is never hardcoded: when `base` is omitted it is resolved
17
+ * fresh from the repo API (`GET /repos/{owner}/{repo}` → `default_branch`),
18
+ * so the PR always targets the repo's REAL default branch unless the caller
19
+ * overrides it explicitly.
20
+ *
21
+ * Transport: plain authenticated `fetch` (Node >= 18) against the one REST
22
+ * endpoint needed, matching src/github/installations.ts — no new runtime
23
+ * dependency (this project deliberately avoids heavy SDKs; @octokit/rest is
24
+ * not even present). The token goes in an `Authorization: Bearer` header and
25
+ * is never logged.
26
+ *
27
+ * Failure honesty: a PR whose head→base has nothing to merge is a REAL GitHub
28
+ * API error (HTTP 422 "No commits between ..." / "The head and base branches
29
+ * are the same"). It is surfaced as a typed NoDiffError — never silently
30
+ * succeeded or retried.
31
+ */
32
+
33
+ /** The function that supplies a fresh installation access token. */
34
+ type TokenSupplier = () => Promise<string>
35
+
36
+ export interface OpenPrOptions {
37
+ installationId: number | string
38
+ owner: string
39
+ repo: string
40
+ /** Branch name pushed in step 1 (the PR's source). */
41
+ head: string
42
+ /**
43
+ * Target branch, e.g. "main"/"master". Omitted → resolved fresh from the
44
+ * repo API's default_branch (never hardcoded here).
45
+ */
46
+ base?: string
47
+ title: string
48
+ body: string
49
+ getToken: TokenSupplier
50
+ /** API base URL override (tests/mocks; default api.github.com). */
51
+ baseUrl?: string
52
+ /** Injectable fetch for tests (default: global fetch). */
53
+ fetchImpl?: typeof fetch
54
+ }
55
+
56
+ export interface OpenPrResult {
57
+ number: number
58
+ /** The human-facing PR page URL (GitHub's `html_url`), not the API URL. */
59
+ url: string
60
+ }
61
+
62
+ /** Non-2xx GitHub API response for one of the PR endpoints. */
63
+ export class OpenPrError extends Error {
64
+ readonly status: number
65
+ readonly url: string
66
+ constructor(status: number, message: string, url: string, options?: ErrorOptions) {
67
+ super(message, options)
68
+ this.name = "OpenPrError"
69
+ this.status = status
70
+ this.url = url
71
+ }
72
+ }
73
+
74
+ /** The PR has nothing to merge (no commits between head and base, or head ===
75
+ * base) — GitHub's real "you cannot open this PR" case. */
76
+ export class NoDiffError extends OpenPrError {
77
+ constructor(status: number, message: string, url: string, options?: ErrorOptions) {
78
+ super(status, message, url, options)
79
+ this.name = "NoDiffError"
80
+ }
81
+ }
82
+
83
+ const DEFAULT_GH_BASE_URL = "https://api.github.com"
84
+ const GH_API_VERSION = "2022-11-28"
85
+ const USER_AGENT = "headlesscode-push-pr"
86
+
87
+ function ghHeaders(token: string): Record<string, string> {
88
+ return {
89
+ Accept: "application/vnd.github+json",
90
+ "X-GitHub-Api-Version": GH_API_VERSION,
91
+ Authorization: `Bearer ${token}`,
92
+ "User-Agent": USER_AGENT,
93
+ "Content-Type": "application/json",
94
+ }
95
+ }
96
+
97
+ interface ErrorPayload {
98
+ message?: unknown
99
+ errors?: unknown
100
+ }
101
+
102
+ async function parseErrorPayload(response: Response): Promise<ErrorPayload> {
103
+ try {
104
+ return (await response.json()) as ErrorPayload
105
+ } catch {
106
+ return {}
107
+ }
108
+ }
109
+
110
+ /** The message GitHub gives for "nothing to merge" (top-level or in errors[]). */
111
+ const NO_DIFF_PATTERNS = [/no commits between/i, /head and base branches are the same/i]
112
+
113
+ function isNoDiff(payload: ErrorPayload): boolean {
114
+ const message = payload.message
115
+ if (typeof message === "string" && NO_DIFF_PATTERNS.some((re) => re.test(message))) {
116
+ return true
117
+ }
118
+ if (Array.isArray(payload.errors)) {
119
+ for (const entry of payload.errors) {
120
+ if (entry === null || typeof entry !== "object") {
121
+ continue
122
+ }
123
+ const entryMessage = (entry as { message?: unknown }).message
124
+ if (typeof entryMessage === "string" && NO_DIFF_PATTERNS.some((re) => re.test(entryMessage))) {
125
+ return true
126
+ }
127
+ }
128
+ }
129
+ return false
130
+ }
131
+
132
+ function formatErrorMessage(payload: ErrorPayload, fallback: string): string {
133
+ if (typeof payload.message === "string" && payload.message !== "") {
134
+ return payload.message
135
+ }
136
+ return fallback
137
+ }
138
+
139
+ /** The repo API's `default_branch` for <owner>/<repo> (never hardcoded). */
140
+ export async function getRepoDefaultBranch(options: {
141
+ owner: string
142
+ repo: string
143
+ getToken: TokenSupplier
144
+ baseUrl?: string
145
+ fetchImpl?: typeof fetch
146
+ }): Promise<string> {
147
+ const { owner, repo, getToken, baseUrl, fetchImpl = fetch } = options
148
+ const token = await getToken()
149
+
150
+ const apiBase = (baseUrl ?? DEFAULT_GH_BASE_URL).replace(/\/+$/, "")
151
+ const url = `${apiBase}/repos/${owner}/${repo}`
152
+
153
+ let response: Response
154
+ try {
155
+ response = await fetchImpl(url, {
156
+ method: "GET",
157
+ headers: ghHeaders(token),
158
+ })
159
+ } catch (err) {
160
+ throw new OpenPrError(
161
+ 0,
162
+ `GitHub API network error (GET ${url}): ${err instanceof Error ? err.message : String(err)}`,
163
+ url,
164
+ { cause: err },
165
+ )
166
+ }
167
+
168
+ if (!response.ok) {
169
+ const payload = await parseErrorPayload(response)
170
+ throw new OpenPrError(response.status, formatErrorMessage(payload, `GitHub API HTTP ${response.status} for GET ${url}`), url)
171
+ }
172
+
173
+ const body = (await response.json()) as { default_branch?: unknown }
174
+ if (typeof body.default_branch !== "string" || body.default_branch === "") {
175
+ throw new OpenPrError(response.status, `repo API for ${owner}/${repo} returned no default_branch`, url)
176
+ }
177
+ return body.default_branch
178
+ }
179
+
180
+ /**
181
+ * Open a PR from `head` into `base` (resolved to the repo's real default
182
+ * branch when omitted) on <owner>/<repo>. Returns the PR number + human-facing
183
+ * URL. A "nothing to merge" API response surfaces as NoDiffError.
184
+ */
185
+ export async function openPullRequest(options: OpenPrOptions): Promise<OpenPrResult> {
186
+ const { owner, repo, head, title, body, getToken, baseUrl, fetchImpl = fetch } = options
187
+ if (!owner || !repo) {
188
+ throw new OpenPrError(0, "openPullRequest: owner and repo are required", "")
189
+ }
190
+ if (!head) {
191
+ throw new OpenPrError(0, "openPullRequest: head is required", "")
192
+ }
193
+ if (!title) {
194
+ throw new OpenPrError(0, "openPullRequest: title is required", "")
195
+ }
196
+
197
+ const token = await getToken()
198
+ if (!token) {
199
+ throw new OpenPrError(0, "openPullRequest: no installation token provided", "")
200
+ }
201
+
202
+ // Resolve the real target branch fresh unless the caller overrode it.
203
+ const base = options.base ?? (await getRepoDefaultBranch({ owner, repo, getToken: async () => token, baseUrl, fetchImpl }))
204
+
205
+ const apiBase = (baseUrl ?? DEFAULT_GH_BASE_URL).replace(/\/+$/, "")
206
+ const url = `${apiBase}/repos/${owner}/${repo}/pulls`
207
+
208
+ let response: Response
209
+ try {
210
+ response = await fetchImpl(url, {
211
+ method: "POST",
212
+ headers: ghHeaders(token),
213
+ body: JSON.stringify({ title, head, base, body }),
214
+ })
215
+ } catch (err) {
216
+ throw new OpenPrError(
217
+ 0,
218
+ `GitHub API network error (POST ${url}): ${err instanceof Error ? err.message : String(err)}`,
219
+ url,
220
+ { cause: err },
221
+ )
222
+ }
223
+
224
+ if (!response.ok) {
225
+ const payload = await parseErrorPayload(response)
226
+ const message = formatErrorMessage(payload, `GitHub API HTTP ${response.status} for POST ${url}`)
227
+ if (isNoDiff(payload)) {
228
+ // Nothing to merge is a real, permanent API rejection (422) — a
229
+ // retry would fail identically. Surface it clearly, never retry.
230
+ throw new NoDiffError(response.status, `nothing to merge: ${message}`, url)
231
+ }
232
+ throw new OpenPrError(response.status, message, url)
233
+ }
234
+
235
+ const pr = (await response.json()) as { number?: unknown; html_url?: unknown }
236
+ if (typeof pr.number !== "number" || typeof pr.html_url !== "string") {
237
+ throw new OpenPrError(response.status, `unexpected PR response shape from POST ${url}`, url)
238
+ }
239
+ return { number: pr.number, url: pr.html_url }
240
+ }
241
+
242
+ export interface PullRequestStatus {
243
+ number: number
244
+ /** "open" | "closed" — the PR's lifecycle state, NOT its merge status. */
245
+ state: string
246
+ /** GitHub's server-computed merge boolean — the authoritative answer. */
247
+ merged: boolean
248
+ mergedAt?: string
249
+ htmlUrl?: string
250
+ title?: string
251
+ }
252
+
253
+ /**
254
+ * Read a PR's status back from `GET /repos/{owner}/{repo}/pulls/{number}`.
255
+ * The `.merged` field is computed server-side by GitHub and is the ONLY
256
+ * correct "is this PR merged" answer for squash/rebase merges (which create
257
+ * a new commit hash unrelated to anything on the source branch). Non-2xx
258
+ * API responses throw OpenPrError exactly like openPullRequest; a malformed
259
+ * 2xx body (missing `merged`/`state`) also throws rather than guessing.
260
+ */
261
+ export async function getPullRequestStatus(options: {
262
+ owner: string
263
+ repo: string
264
+ prNumber: number
265
+ getToken: TokenSupplier
266
+ baseUrl?: string
267
+ fetchImpl?: typeof fetch
268
+ }): Promise<PullRequestStatus> {
269
+ const { owner, repo, prNumber, getToken, baseUrl, fetchImpl = fetch } = options
270
+ if (!owner || !repo || !Number.isInteger(prNumber) || prNumber <= 0) {
271
+ throw new OpenPrError(0, "getPullRequestStatus: owner, repo and a positive prNumber are required", "")
272
+ }
273
+
274
+ const token = await getToken()
275
+ if (!token) {
276
+ throw new OpenPrError(0, "getPullRequestStatus: no installation token provided", "")
277
+ }
278
+
279
+ const apiBase = (baseUrl ?? DEFAULT_GH_BASE_URL).replace(/\/+$/, "")
280
+ const url = `${apiBase}/repos/${owner}/${repo}/pulls/${prNumber}`
281
+
282
+ let response: Response
283
+ try {
284
+ response = await fetchImpl(url, {
285
+ method: "GET",
286
+ headers: ghHeaders(token),
287
+ })
288
+ } catch (err) {
289
+ throw new OpenPrError(
290
+ 0,
291
+ `GitHub API network error (GET ${url}): ${err instanceof Error ? err.message : String(err)}`,
292
+ url,
293
+ { cause: err },
294
+ )
295
+ }
296
+
297
+ if (!response.ok) {
298
+ const payload = await parseErrorPayload(response)
299
+ throw new OpenPrError(response.status, formatErrorMessage(payload, `GitHub API HTTP ${response.status} for GET ${url}`), url)
300
+ }
301
+
302
+ const body = (await response.json()) as {
303
+ number?: unknown
304
+ state?: unknown
305
+ merged?: unknown
306
+ merged_at?: unknown
307
+ html_url?: unknown
308
+ title?: unknown
309
+ }
310
+ if (typeof body.number !== "number" || typeof body.state !== "string" || typeof body.merged !== "boolean") {
311
+ throw new OpenPrError(response.status, `unexpected PR status shape from GET ${url}`, url)
312
+ }
313
+ return {
314
+ number: body.number,
315
+ state: body.state,
316
+ merged: body.merged,
317
+ ...(typeof body.merged_at === "string" ? { mergedAt: body.merged_at } : {}),
318
+ ...(typeof body.html_url === "string" ? { htmlUrl: body.html_url } : {}),
319
+ ...(typeof body.title === "string" ? { title: body.title } : {}),
320
+ }
321
+ }
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Repo provisioning: installation + repo → a real local clone.
3
+ *
4
+ * `provisionRepo()` gets a fresh installation access token (via
5
+ * src/github/app-auth.ts), constructs GitHub's documented authenticated clone
6
+ * URL for App installation tokens
7
+ * (https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/authenticating-as-a-github-app-installation:
8
+ * `git clone https://x-access-token:TOKEN@github.com/owner/repo.git`), clones
9
+ * into `targetDir`, then REWRITES the remote to a plain token-free
10
+ * `https://github.com/owner/repo.git` so the token never lingers in
11
+ * `.git/config`. The resulting path is exactly what the rest of headlesscode
12
+ * expects as a `--workspace` value — no downstream changes needed.
13
+ *
14
+ * The token never touches disk beyond the transient clone URL (and even that
15
+ * is scrubbed immediately after): it is never written to the cloned repo's
16
+ * config, never logged, never persisted.
17
+ */
18
+
19
+ import { execFile } from "node:child_process"
20
+ import * as fs from "node:fs"
21
+ import * as path from "node:path"
22
+ import { promisify } from "node:util"
23
+
24
+ const execFileP = promisify(execFile)
25
+
26
+ export interface ProvisionOptions {
27
+ installationId: number | string
28
+ owner: string
29
+ repo: string
30
+ /** Local directory to clone into. Created if missing; must be empty if it exists. */
31
+ targetDir: string
32
+ /** The function that supplies a fresh installation access token. */
33
+ getToken: () => Promise<string>
34
+ /** Optional explicit git binary (tests). Defaults to "git" on PATH. */
35
+ gitBin?: string
36
+ }
37
+
38
+ export class ProvisionError extends Error {
39
+ constructor(message: string, options?: ErrorOptions) {
40
+ super(message, options)
41
+ this.name = "ProvisionError"
42
+ }
43
+ }
44
+
45
+ function isDirEmpty(dir: string): boolean {
46
+ return fs.readdirSync(dir).length === 0
47
+ }
48
+
49
+ /**
50
+ * Clone <owner>/<repo> into <targetDir> using a fresh installation token, then
51
+ * strip the token out of the clone's remote URL. Returns the absolute local
52
+ * path, ready for use as a `--workspace` value.
53
+ */
54
+ export async function provisionRepo(options: ProvisionOptions): Promise<string> {
55
+ const { installationId, owner, repo, targetDir, getToken, gitBin = "git" } = options
56
+ if (!owner || !repo) {
57
+ throw new ProvisionError("provisionRepo: owner and repo are required")
58
+ }
59
+ if (!targetDir) {
60
+ throw new ProvisionError("provisionRepo: targetDir is required")
61
+ }
62
+
63
+ const target = path.resolve(targetDir)
64
+
65
+ // Refuse to silently overwrite: the target must be missing, or an existing
66
+ // EMPTY directory (a stray empty dir is fine to clone into).
67
+ if (fs.existsSync(target)) {
68
+ if (!fs.statSync(target).isDirectory()) {
69
+ throw new ProvisionError(`provisionRepo: target exists and is not a directory: ${target}`)
70
+ }
71
+ if (!isDirEmpty(target)) {
72
+ throw new ProvisionError(`provisionRepo: target directory is not empty: ${target}`)
73
+ }
74
+ } else {
75
+ fs.mkdirSync(target, { recursive: true })
76
+ }
77
+
78
+ const token = await getToken()
79
+ if (!token) {
80
+ throw new ProvisionError("provisionRepo: no installation token provided")
81
+ }
82
+
83
+ // GitHub's documented pattern for App installation tokens:
84
+ // git clone https://x-access-token:<token>@github.com/<owner>/<repo>.git
85
+ const authenticatedUrl = `https://x-access-token:${token}@github.com/${owner}/${repo}.git`
86
+ const cleanUrl = `https://github.com/${owner}/${repo}.git`
87
+
88
+ try {
89
+ await execFileP(gitBin, ["clone", "--quiet", authenticatedUrl, target], { timeout: 120_000 })
90
+ } catch (err) {
91
+ throw new ProvisionError(
92
+ `provisionRepo: git clone failed for ${owner}/${repo}: ${err instanceof Error ? err.message : String(err)}`,
93
+ { cause: err },
94
+ )
95
+ }
96
+
97
+ // Security: rewrite the remote to a plain URL immediately, so the token
98
+ // does not linger in <target>/.git/config for the life of the clone.
99
+ try {
100
+ await execFileP(gitBin, ["-C", target, "remote", "set-url", "origin", cleanUrl], { timeout: 30_000 })
101
+ } catch (err) {
102
+ throw new ProvisionError(
103
+ `provisionRepo: clone succeeded but failed to strip the token from the remote URL: ${
104
+ err instanceof Error ? err.message : String(err)
105
+ }`,
106
+ { cause: err },
107
+ )
108
+ }
109
+
110
+ return target
111
+ }
112
+
113
+ /** Read the clone's origin URL (exported so the security test can assert on
114
+ * the ACTUAL .git/config contents, not on our own assumptions). */
115
+ export async function readOriginUrl(repoDir: string, gitBin = "git"): Promise<string> {
116
+ const { stdout } = await execFileP(gitBin, ["-C", repoDir, "remote", "get-url", "origin"])
117
+ return stdout.trim()
118
+ }
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Repo push-back: push a local branch to GitHub using the installation token.
3
+ *
4
+ * `pushBranch()` is the write half of repo provisioning: it gets a fresh
5
+ * installation access token (same `getToken` shape as `provisionRepo` in
6
+ * src/github/provision.ts), pushes the local HEAD to an explicitly NAMED
7
+ * branch on <owner>/<repo>, and strips the token out of the repo's origin URL
8
+ * before returning.
9
+ *
10
+ * Token discipline matches provision.ts exactly: the token only ever appears
11
+ * inside a transient `https://x-access-token:<token>@github.com/...` URL. The
12
+ * push is done via `git remote set-url origin <auth-url>` → `git push origin
13
+ * HEAD:<branch>` → `git remote set-url origin <original>` in a `finally`, so
14
+ * the token never persists in `.git/config` past this call — even when the
15
+ * push itself fails. The ORIGINAL origin URL is captured first and restored
16
+ * verbatim (for a provisioned repo that is the token-free
17
+ * `https://github.com/<owner>/<repo>.git`, but whatever it was, it ends up
18
+ * exactly as it started).
19
+ *
20
+ * Never pushes to a repo's default branch: the refspec is always the explicit
21
+ * `HEAD:<branchName>` — there is no code path that pushes to an implicit
22
+ * upstream/default branch. The CLI layer additionally refuses
23
+ * `--branch == --base` (see src/github/cli.ts).
24
+ */
25
+
26
+ import { execFile } from "node:child_process"
27
+ import { promisify } from "node:util"
28
+
29
+ const execFileP = promisify(execFile)
30
+
31
+ export interface PushOptions {
32
+ installationId: number | string
33
+ owner: string
34
+ repo: string
35
+ /** Local repo path to push from (already has the work committed). */
36
+ localDir: string
37
+ /** Branch name to push to on the remote (created if it doesn't exist). */
38
+ branchName: string
39
+ /** The function that supplies a fresh installation access token. */
40
+ getToken: () => Promise<string>
41
+ /** Optional explicit git binary (tests). Defaults to "git" on PATH. */
42
+ gitBin?: string
43
+ }
44
+
45
+ export class PushError extends Error {
46
+ constructor(message: string, options?: ErrorOptions) {
47
+ super(message, options)
48
+ this.name = "PushError"
49
+ }
50
+ }
51
+
52
+ /**
53
+ * Push the current HEAD of <localDir> to <branchName> on <owner>/<repo>,
54
+ * using a fresh installation token, then restore the origin URL to its
55
+ * original token-free form. The token never persists past this call.
56
+ */
57
+ export async function pushBranch(options: PushOptions): Promise<void> {
58
+ const { owner, repo, localDir, branchName, getToken, gitBin = "git" } = options
59
+ if (!owner || !repo) {
60
+ throw new PushError("pushBranch: owner and repo are required")
61
+ }
62
+ if (!localDir) {
63
+ throw new PushError("pushBranch: localDir is required")
64
+ }
65
+ if (!branchName) {
66
+ throw new PushError("pushBranch: branchName is required")
67
+ }
68
+
69
+ const token = await getToken()
70
+ if (!token) {
71
+ throw new PushError("pushBranch: no installation token provided")
72
+ }
73
+
74
+ // GitHub's documented pattern for App installation tokens (same as
75
+ // provision.ts): git push https://x-access-token:<token>@github.com/<owner>/<repo>.git
76
+ const authenticatedUrl = `https://x-access-token:${token}@github.com/${owner}/${repo}.git`
77
+
78
+ // Capture the CURRENT origin URL BEFORE touching anything. Whatever it is
79
+ // (a provisioned repo's token-free clean URL, or anything else), it gets
80
+ // restored verbatim after the push — so this call is a no-op on the
81
+ // remote config except for the transient authenticated window inside it.
82
+ let originalUrl: string
83
+ try {
84
+ const { stdout } = await execFileP(gitBin, ["-C", localDir, "remote", "get-url", "origin"], { timeout: 30_000 })
85
+ originalUrl = stdout.trim()
86
+ } catch (err) {
87
+ throw new PushError(
88
+ `pushBranch: cannot read the 'origin' remote URL in ${localDir}: ${err instanceof Error ? err.message : String(err)}`,
89
+ { cause: err },
90
+ )
91
+ }
92
+
93
+ try {
94
+ await execFileP(gitBin, ["-C", localDir, "remote", "set-url", "origin", authenticatedUrl], { timeout: 30_000 })
95
+ // Always an EXPLICIT named-branch refspec: `git push origin HEAD:<name>`
96
+ // creates/updates exactly <name> on the remote and never touches the
97
+ // repo's default branch (no bare `git push`, no upstream inference).
98
+ await execFileP(gitBin, ["-C", localDir, "push", "origin", `HEAD:${branchName}`], { timeout: 120_000 })
99
+ } catch (err) {
100
+ throw new PushError(
101
+ `pushBranch: git push ${owner}/${repo} failed for branch '${branchName}': ${
102
+ err instanceof Error ? err.message : String(err)
103
+ }`,
104
+ { cause: err },
105
+ )
106
+ } finally {
107
+ // Security: strip the token out of .git/config IMMEDIATELY — including
108
+ // on failure. If the restore itself fails, that is the one error we
109
+ // cannot paper over (the token may be on disk), so it REPLACES any
110
+ // push error.
111
+ try {
112
+ await execFileP(gitBin, ["-C", localDir, "remote", "set-url", "origin", originalUrl], { timeout: 30_000 })
113
+ } catch (err) {
114
+ throw new PushError(
115
+ `pushBranch: failed to strip the token from the origin URL in ${localDir}/.git/config: ${
116
+ err instanceof Error ? err.message : String(err)
117
+ }`,
118
+ { cause: err },
119
+ )
120
+ }
121
+ }
122
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Workspace codebase-index detection helpers.
3
+ *
4
+ * The codebase-search index now lives in the CENTRAL per-project data store
5
+ * (see src/project-store.ts): `<dataRoot>/projects/<project-key>/codesearch/
6
+ * index.jsonl`, keyed by the resolved git-common-dir parent so every worktree
7
+ * of a repo shares the SAME index — the property that made the old
8
+ * spawn-script index-seeding step unnecessary. These helpers answer "does a
9
+ * usable index exist here?" in one place, used by:
10
+ * - HeadlessSession (src/engine/loop.ts) for the read-only-nudge guardrail,
11
+ * - the spawn-parallel-worktrees scripts (which decide whether a fresh
12
+ * worktree already has an index — now a central-store lookup, no seeding),
13
+ * - the spawn-parallel-worktrees smoke test.
14
+ *
15
+ * A "usable" index is one that exists AND is non-empty (an empty file is a
16
+ * crashed/partial build — treat it as absent so the spawner rebuilds it).
17
+ */
18
+
19
+ import * as fsp from "node:fs/promises"
20
+ import * as path from "node:path"
21
+
22
+ import { resolveProjectDataDir } from "./project-store.js"
23
+
24
+ /** Absolute path of the codebase-search index for a workspace root. */
25
+ export function indexFilePathFor(workspaceRoot: string): string {
26
+ return path.join(resolveProjectDataDir(workspaceRoot), "codesearch", "index.jsonl")
27
+ }
28
+
29
+ /**
30
+ * True when the workspace already has a non-empty codebase-search index.
31
+ * Also counts a legacy workspace-relative `.headlesscode/codesearch/index.jsonl`
32
+ * (pre-migration grace — the same rule the spawn scripts rely on so a fresh
33
+ * worktree of an already-indexed repo never rebuilds unnecessarily).
34
+ */
35
+ export async function hasCodebaseIndex(workspaceRoot: string): Promise<boolean> {
36
+ for (const file of [
37
+ indexFilePathFor(workspaceRoot),
38
+ path.join(workspaceRoot, ".headlesscode", "codesearch", "index.jsonl"),
39
+ ]) {
40
+ try {
41
+ const st = await fsp.stat(file)
42
+ if (st.isFile() && st.size > 0) {
43
+ return true
44
+ }
45
+ } catch {
46
+ // missing — try the next candidate
47
+ }
48
+ }
49
+ return false
50
+ }
package/src/index.ts ADDED
@@ -0,0 +1,81 @@
1
+ /**
2
+ * headlesscode package entry — runs the CLI when executed directly
3
+ * (`tsx src/index.ts`, `npm run dev`) and re-exports the public API for
4
+ * programmatic use.
5
+ */
6
+
7
+ import { main } from "./cli.js"
8
+
9
+ // Run the CLI when this file is executed directly (e.g. `tsx src/index.ts`).
10
+ main()
11
+ .then((code) => {
12
+ process.exitCode = code
13
+ })
14
+ .catch((err) => {
15
+ process.stderr.write(`headlesscode: unexpected error: ${err instanceof Error ? err.stack ?? err.message : String(err)}\n`)
16
+ process.exitCode = 2
17
+ })
18
+
19
+ export * from "./cli.js"
20
+ export * from "./memory/index.js"
21
+ export { OpenRouterClient, OpenRouterError, DEFAULT_MODEL } from "./llm/openrouter.js"
22
+ export { HeadlessSession, truncateHistory, DEFAULT_MAX_TOKENS } from "./engine/loop.js"
23
+ export {
24
+ maybeCondense,
25
+ condenseOldestTurns,
26
+ computeCondenseCount,
27
+ computeCondensePlan,
28
+ DEFAULT_CONDENSE_THRESHOLD_FRACTION,
29
+ DEFAULT_CONDENSE_EARLY_FIRE_FRACTION,
30
+ DEFAULT_CONDENSE_MAX_TOKENS,
31
+ DEFAULT_CONTEXT_WINDOW_TOKENS,
32
+ } from "./engine/condense.js"
33
+ export {
34
+ runLocalExplorePhase,
35
+ buildLocalExploreHandoffMessage,
36
+ buildLocalExploreTools,
37
+ OllamaLocalChatClient,
38
+ LocalExploreError,
39
+ isLocalExploreEnabled,
40
+ LOCAL_EXPLORE_ENV,
41
+ DEFAULT_LOCAL_EXPLORE_MODEL,
42
+ DEFAULT_LOCAL_EXPLORE_MAX_ITERATIONS,
43
+ DEFAULT_LOCAL_EXPLORE_CONTEXT_TOKENS,
44
+ EXPLORE_SYSTEM_PROMPT,
45
+ } from "./engine/local-explore.js"
46
+ export { Logger } from "./engine/logger.js"
47
+ export { parseToolCalls, parseToolCall, bestEffortPartialJson } from "./engine/parser.js"
48
+ export {
49
+ createHeadlessExecutor,
50
+ ToolExecutor,
51
+ resolveWithinWorkspace,
52
+ PathTraversalError,
53
+ } from "./tools/executor.js"
54
+ export {
55
+ buildSystemPrompt,
56
+ loadCustomModes,
57
+ selectToolsForMode,
58
+ EXECUTABLE_TOOL_NAMES,
59
+ } from "./engine/prompt.js"
60
+ export {
61
+ detectStacks,
62
+ loadStackRules,
63
+ appendStackRulesSection,
64
+ STACK_NAMES,
65
+ STACK_RULES_HEADER,
66
+ getCentralStackRulesDir,
67
+ getCentralStackRulesFile,
68
+ getProjectStackRulesFile,
69
+ } from "./engine/stacks.js"
70
+ export type { StackName, LoadedStackRules } from "./engine/stacks.js"
71
+ export type {
72
+ ChatMessage,
73
+ ChatTool,
74
+ ChatToolCall,
75
+ LlmClient,
76
+ LlmRequest,
77
+ LlmResponse,
78
+ ParsedToolCall,
79
+ SessionResult,
80
+ ToolResult,
81
+ } from "./engine/types.js"