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,648 @@
1
+ /**
2
+ * Central per-project data store (plans/central-project-store-and-shared-instructions.md).
3
+ *
4
+ * Every per-project, cross-session data artifact that previously lived under a
5
+ * workspace-relative `<workspaceRoot>/.headlesscode/` directory (codebase-search
6
+ * index, mode-models.json, permissions.json) — and required manual seeding per
7
+ * directory and per new worktree — now lives under ONE central, git-worktree-aware
8
+ * location:
9
+ *
10
+ * ~/.local/share/headlesscode/ (XDG data-dir convention; overridable
11
+ * via $HEADLESSCODE_DATA_DIR for tests)
12
+ * projects/
13
+ * <project-key>/
14
+ * codesearch/index.jsonl(.meta.json)
15
+ * mode-models.json
16
+ * permissions.json
17
+ * project.json (metadata: real path, kind, first-seen)
18
+ * checkpoints/ (moved from ~/.headlesscode/checkpoints)
19
+ * shared/ (Part B: modes.yaml + rules(-<mode>)/)
20
+ * settings.json (Part C: cross-project operational defaults)
21
+ *
22
+ * Project identity is resolved via git, NOT the raw workspace path:
23
+ *
24
+ * - inside a git repo, `git rev-parse --git-common-dir` (resolved to an
25
+ * absolute path, then its parent) is the identity source. From INSIDE A
26
+ * WORKTREE this resolves to the MAIN repo's `.git`, so every worktree of a
27
+ * repo collapses onto the SAME central store as the main checkout — the
28
+ * property that makes the old spawn-script index/mode-models seeding
29
+ * obsolete (worktrees share the store for free).
30
+ * - a plain non-git directory falls back to its resolved (symlinks-followed)
31
+ * realpath.
32
+ *
33
+ * The identity string is hashed (sha256, truncated) into `<project-key>` — a
34
+ * directory name, not a security boundary. `project.json` is written alongside
35
+ * recording the real path the key was derived from, so a human inspecting
36
+ * `projects/` can tell which directory is which without re-deriving the hash.
37
+ *
38
+ * One-time migration: the first run against a workspace that still has an
39
+ * old-style `<workspaceRoot>/.headlesscode/` (index / mode-models.json /
40
+ * permissions.json) moves that content into the central store — verified before
41
+ * the source is removed, so real index data (a large repo's ~700MB) is never
42
+ * orphaned or half-copied. Checkpoints move from `~/.headlesscode/checkpoints`
43
+ * and the global modes/rules move from `~/.roo/` the same way. A human can
44
+ * trigger all migrations explicitly via `headlesscode migrate`.
45
+ */
46
+
47
+ import * as crypto from "node:crypto"
48
+ import * as fs from "node:fs"
49
+ import * as os from "node:os"
50
+ import * as path from "node:path"
51
+ import { execFileSync } from "node:child_process"
52
+
53
+ /**
54
+ * The central data root. `$HEADLESSCODE_DATA_DIR` overrides it (used by tests
55
+ * to redirect everything under a temp dir; also a legit per-machine escape
56
+ * hatch). Default: the XDG data-dir convention `~/.local/share/headlesscode`.
57
+ */
58
+ export function projectStoreRoot(): string {
59
+ const override = process.env.HEADLESSCODE_DATA_DIR?.trim()
60
+ return override ? path.resolve(override) : path.join(os.homedir(), ".local", "share", "headlesscode")
61
+ }
62
+
63
+ /** The shared-instructions subtree (Part B): `<root>/shared`. */
64
+ export function sharedInstructionsRoot(): string {
65
+ return path.join(projectStoreRoot(), "shared")
66
+ }
67
+
68
+ export interface ProjectIdentity {
69
+ /**
70
+ * Absolute filesystem path the project key is derived from: the parent of
71
+ * the resolved `--git-common-dir` (the MAIN repo root) for git repos, or
72
+ * the resolved realpath of the workspace root for plain directories.
73
+ */
74
+ keySource: string
75
+ /** "git" when inside a git repo (worktrees collapse to the main repo), "plain" otherwise. */
76
+ kind: "git" | "plain"
77
+ }
78
+
79
+ /** The `<project-key>/project.json` metadata schema (see writeProjectMetadata). */
80
+ export interface ProjectMetadata {
81
+ /** Absolute filesystem path the project key was derived from. */
82
+ path: string
83
+ kind: ProjectIdentity["kind"]
84
+ /** ISO-8601 timestamp of first recorded contact (absent in pre-Part-B files). */
85
+ firstSeen?: string
86
+ /** ISO-8601 timestamp of the most recent contact (touched on every resolve). */
87
+ lastSeen?: string
88
+ /** True only when a human deliberately ran `headlesscode init` on this project. */
89
+ registered: boolean
90
+ }
91
+
92
+ /**
93
+ * Resolve the project identity source for a workspace root. Purely
94
+ * filesystem-derived — zero registration, any directory works on first contact.
95
+ */
96
+ export function resolveProjectIdentity(workspaceRoot: string): ProjectIdentity {
97
+ const root = path.resolve(workspaceRoot)
98
+ // 1. Git repo → `--git-common-dir` (worktree-aware: resolves to the main
99
+ // repo's .git from inside a worktree), parent = identity source.
100
+ try {
101
+ const out = execFileSync("git", ["rev-parse", "--git-common-dir"], {
102
+ cwd: root,
103
+ encoding: "utf-8",
104
+ timeout: 10_000,
105
+ stdio: ["ignore", "pipe", "ignore"],
106
+ }).trim()
107
+ if (out !== "") {
108
+ const commonDir = path.isAbsolute(out) ? out : path.resolve(root, out)
109
+ return { keySource: realpathOrResolve(path.dirname(commonDir)), kind: "git" }
110
+ }
111
+ } catch (err) {
112
+ // Not a git repo (or git missing) — fall through to the plain fallback.
113
+ // Issue #83: "not a git repository" (exit 128) is the ordinary,
114
+ // expected case for a plain directory and would spam every non-repo
115
+ // workspace's log for no reason — only warn on a genuinely surprising
116
+ // failure (git binary missing, permission denied, unexpected exit
117
+ // code), so a real broken git invocation is still visible instead of
118
+ // silently mis-keying the project.
119
+ const status = (err as NodeJS.ErrnoException & { status?: number }).status
120
+ const code = (err as NodeJS.ErrnoException).code
121
+ if (code === "ENOENT" || (status !== undefined && status !== 128)) {
122
+ process.stderr.write(
123
+ `[project-store] git identity lookup failed for ${root} (falling back to plain-directory identity): ${err instanceof Error ? err.message : String(err)}\n`,
124
+ )
125
+ }
126
+ }
127
+ // 2. Plain directory → resolved absolute realpath of the workspace root.
128
+ return { keySource: realpathOrResolve(root), kind: "plain" }
129
+ }
130
+
131
+ /** realpath when possible, else the plain resolved path (e.g. for a nonexistent root). */
132
+ function realpathOrResolve(p: string): string {
133
+ try {
134
+ return fs.realpathSync(p)
135
+ } catch {
136
+ return path.resolve(p)
137
+ }
138
+ }
139
+
140
+ /** Derive `<project-key>` from the identity source (sha256, truncated to 16 hex chars). */
141
+ export function projectKeyFor(identitySource: string): string {
142
+ return crypto.createHash("sha256").update(identitySource, "utf-8").digest("hex").slice(0, 16)
143
+ }
144
+
145
+ const projectDirCache = new Map<string, string>()
146
+
147
+ /** Migration log default: stderr (never pollutes stdout data streams). */
148
+ const defaultMigrationLog = (msg: string): void => {
149
+ process.stderr.write(`[headlesscode-migrate] ${msg}\n`)
150
+ }
151
+
152
+ export interface MigrateOptions {
153
+ /** Log sink for migration messages (default: stderr). */
154
+ log?: (msg: string) => void
155
+ }
156
+
157
+ /**
158
+ * Resolve the central data dir for a workspace: `<root>/projects/<project-key>`.
159
+ * Cached per process per (resolved) workspace root, so the git exec + project.json
160
+ * write happen at most once per session. Also performs the one-time legacy
161
+ * `.headlesscode/` migration on first resolution (see migrateLegacyProjectData) —
162
+ * SKIPPED under a $HEADLESSCODE_DATA_DIR override, exactly like the
163
+ * checkpoint/shared-instructions auto-migrations: the override means "never move
164
+ * real data", and the test suites run with an override against real workspace
165
+ * roots (e.g. the run_tests end-to-end test runs with cwd = this repo). Under an
166
+ * override the legacy files are still READ via the pre-migration-grace fallback
167
+ * paths in mode-models/permissions/codesearch-index instead.
168
+ */
169
+ export function resolveProjectDataDir(
170
+ workspaceRoot: string,
171
+ options: { registered?: boolean } = {},
172
+ ): string {
173
+ const root = path.resolve(workspaceRoot)
174
+ const cached = projectDirCache.get(root)
175
+ if (cached !== undefined) {
176
+ return cached
177
+ }
178
+ const { keySource, kind } = resolveProjectIdentity(root)
179
+ const key = projectKeyFor(keySource)
180
+ const dir = path.join(projectStoreRoot(), "projects", key)
181
+ projectDirCache.set(root, dir)
182
+ // The project dir is always ensured (callers write index/mode-models/
183
+ // permissions directly into it).
184
+ try {
185
+ fs.mkdirSync(dir, { recursive: true })
186
+ } catch {
187
+ // Non-fatal: a read-only store root falls back to legacy reads.
188
+ }
189
+ // Neither the metadata write nor the migration touches the store under a
190
+ // $HEADLESSCODE_DATA_DIR override (the override means "test/scratch — don't
191
+ // leave real store entries behind"; direct test runs construct executors
192
+ // against throwaway /tmp workspaces, and without this guard every one of
193
+ // them would litter the real ~/.local/share/headlesscode with a
194
+ // projects/<hash>/project.json stub). The same applies when the resolved
195
+ // keySource itself lives under the OS temp dir AND the store is the REAL
196
+ // one: production code paths never create real workspaces under
197
+ // os.tmpdir() (grep src/ for mkdtemp — only test files do), so it's a
198
+ // reliable "ephemeral test/scratch" signal — BUT only when the write would
199
+ // land in the real store. A test that sandboxes HOME under os.tmpdir()
200
+ // (e.g. the init CLI's end-to-end registration test, which drops the
201
+ // override to exercise the real no-override registration path) has both
202
+ // its workspace AND its store under the temp dir; writing project.json
203
+ // into that throwaway store is harmless and must keep working. The project
204
+ // dir is always created (callers need somewhere to write
205
+ // permissions.json/mode-models.json/index files); the guard only decides
206
+ // whether a project.json gets stamped in and the legacy migration runs.
207
+ if (!storeOverridden() && (isUnderSystemTmpDir(projectStoreRoot()) || !isUnderSystemTmpDir(keySource))) {
208
+ writeProjectMetadata(dir, keySource, kind, { registered: options.registered })
209
+ migrateLegacyProjectData(root, keySource, dir)
210
+ }
211
+ return dir
212
+ }
213
+
214
+ /** True when `p` resolves inside the OS temp directory (os.tmpdir()). */
215
+ export function isUnderSystemTmpDir(p: string): boolean {
216
+ const tmp = realpathOrResolve(os.tmpdir())
217
+ const rel = path.relative(tmp, p)
218
+ return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel))
219
+ }
220
+
221
+ /** Project metadata file name inside each `<project-key>/` dir. */
222
+ export const PROJECT_METADATA_FILE = "project.json"
223
+
224
+ /** Options controlling a project.json upsert. */
225
+ export interface WriteProjectMetadataOptions {
226
+ /**
227
+ * True ONLY from `headlesscode init` (a human deliberately registering the
228
+ * project). Never downgraded by later contact: once true, it stays true
229
+ * until `headlesscode projects prune` removes the store entry.
230
+ */
231
+ registered?: boolean
232
+ }
233
+
234
+ /**
235
+ * Upsert `<project-key>/project.json`: a fresh write on first contact
236
+ * (firstSeen = lastSeen = now, registered only when the caller opts in) and a
237
+ * touch on every later contact (lastSeen = now; firstSeen and registered
238
+ * preserved — registered is only ever promoted to true, never demoted back).
239
+ */
240
+ export function writeProjectMetadata(
241
+ dir: string,
242
+ keySource: string,
243
+ kind: ProjectIdentity["kind"],
244
+ options: WriteProjectMetadataOptions = {},
245
+ ): void {
246
+ try {
247
+ const metaPath = path.join(dir, PROJECT_METADATA_FILE)
248
+ const now = new Date().toISOString()
249
+ let existing: Record<string, unknown> = {}
250
+ if (fs.existsSync(metaPath)) {
251
+ try {
252
+ const parsed = JSON.parse(fs.readFileSync(metaPath, "utf-8")) as Record<string, unknown>
253
+ if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) {
254
+ existing = parsed
255
+ }
256
+ } catch {
257
+ // Malformed existing file — fall through and rewrite fresh.
258
+ }
259
+ }
260
+ const registered = options.registered === true || existing.registered === true
261
+ const firstSeen = typeof existing.firstSeen === "string" ? existing.firstSeen : now
262
+ fs.mkdirSync(dir, { recursive: true })
263
+ fs.writeFileSync(
264
+ metaPath,
265
+ JSON.stringify({ path: keySource, kind, firstSeen, lastSeen: now, registered }, null, 2) + "\n",
266
+ "utf-8",
267
+ )
268
+ } catch (err) {
269
+ // Non-fatal: metadata is a human-convenience, never load-bearing.
270
+ // Issue #83: still log it — a silently failing metadata write would
271
+ // otherwise be invisible in harness.log.
272
+ process.stderr.write(
273
+ `[project-store] failed to write project metadata under ${dir} (non-fatal): ${err instanceof Error ? err.message : String(err)}\n`,
274
+ )
275
+ }
276
+ }
277
+
278
+ /** Loose-parse `<project-key>/project.json`; undefined when absent, {} when malformed. */
279
+ function readProjectMetadata(dir: string): Partial<ProjectMetadata> | undefined {
280
+ const metaPath = path.join(dir, PROJECT_METADATA_FILE)
281
+ let raw: string
282
+ try {
283
+ raw = fs.readFileSync(metaPath, "utf-8")
284
+ } catch {
285
+ return undefined
286
+ }
287
+ try {
288
+ const parsed = JSON.parse(raw) as Record<string, unknown>
289
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
290
+ return {}
291
+ }
292
+ return parsed as Partial<ProjectMetadata>
293
+ } catch {
294
+ return {}
295
+ }
296
+ }
297
+
298
+ /** One row of `headlesscode projects list` / GET /api/projects. */
299
+ export interface ProjectListEntry {
300
+ /** `<project-key>` — the store directory name. */
301
+ key: string
302
+ /** Real filesystem path from project.json; undefined when the file is missing/unparseable. */
303
+ path: string | undefined
304
+ kind: ProjectIdentity["kind"] | undefined
305
+ firstSeen: string | undefined
306
+ lastSeen: string | undefined
307
+ registered: boolean
308
+ /** Live fs.existsSync(path) check; false when path is undefined. */
309
+ exists: boolean
310
+ /** GitHub "owner/name" from the repo's origin remote (e.g.
311
+ * "owner/repo"); undefined when unparseable. */
312
+ gitOwnerName: string | undefined
313
+ }
314
+
315
+ /**
316
+ * Enumerate every directory in `<root>/projects/`. A dir with a missing or
317
+ * unparseable project.json still yields an entry (registered:false,
318
+ * exists:false, everything else undefined) — those are exactly the
319
+ * pre-Part-A litter already on disk, and they must be visible/prunable too,
320
+ * not silently skipped. Does NOT compute directory sizes (walking 2500+ dirs
321
+ * recursively would make `list` slow on a polluted store); callers that want
322
+ * sizes compute them lazily only for the entries they print.
323
+ */
324
+ function gitRemoteOwnerName(projectPath: string | undefined): string | undefined {
325
+ if (!projectPath) return undefined
326
+ try {
327
+ const url = execFileSync("git", ["-C", projectPath, "remote", "get-url", "origin"], {
328
+ encoding: "utf8",
329
+ timeout: 5000,
330
+ stdio: ["ignore", "pipe", "ignore"],
331
+ }).trim()
332
+ if (!url) return undefined
333
+ // git@host:owner/name.git | https://host/owner/name.git
334
+ const ownerName = url.startsWith("git@")
335
+ ? url.slice(url.indexOf(":") + 1)
336
+ : url.includes("://")
337
+ ? url.slice(url.indexOf("://") + 3).split("/").slice(1).join("/")
338
+ : url
339
+ return ownerName.replace(/\.git$/, "") || undefined
340
+ } catch {
341
+ return undefined
342
+ }
343
+ }
344
+
345
+ export function listProjectEntries(): ProjectListEntry[] {
346
+ const root = path.join(projectStoreRoot(), "projects")
347
+ let dirNames: string[]
348
+ try {
349
+ dirNames = fs.readdirSync(root)
350
+ } catch {
351
+ // Store root doesn't exist yet (or is unreadable) — nothing to list.
352
+ return []
353
+ }
354
+ const entries: ProjectListEntry[] = []
355
+ for (const name of dirNames) {
356
+ const dir = path.join(root, name)
357
+ try {
358
+ if (!fs.statSync(dir).isDirectory()) {
359
+ continue
360
+ }
361
+ } catch {
362
+ continue
363
+ }
364
+ const meta = readProjectMetadata(dir)
365
+ const projectPath = meta?.path
366
+ entries.push({
367
+ key: name,
368
+ path: projectPath,
369
+ kind: meta?.kind,
370
+ firstSeen: meta?.firstSeen,
371
+ lastSeen: meta?.lastSeen,
372
+ registered: meta?.registered === true,
373
+ exists: projectPath !== undefined && fs.existsSync(projectPath),
374
+ gitOwnerName: gitRemoteOwnerName(projectPath),
375
+ })
376
+ }
377
+ return entries
378
+ }
379
+
380
+ /** Old workspace-relative config dir name (also referenced from spawn scripts' docs). */
381
+ export const LEGACY_WORKSPACE_CONFIG_DIR = ".headlesscode"
382
+
383
+ /** Legacy `.headlesscode/<rel>` → central `<dest>` migration pairs. */
384
+ const LEGACY_MIGRATION_ENTRIES: Array<{ rel: string; dest: string }> = [
385
+ { rel: "codesearch", dest: "codesearch" },
386
+ { rel: "mode-models.json", dest: "mode-models.json" },
387
+ { rel: "permissions.json", dest: "permissions.json" },
388
+ ]
389
+
390
+ /**
391
+ * One-time migration of old-style workspace-relative `.headlesscode/` content
392
+ * (codebase index, mode-models.json, permissions.json) into the central store.
393
+ * Idempotent: skips an entry when the central target already exists or the
394
+ * legacy source is absent. Checks BOTH the given workspace root and the
395
+ * identity-derived main-repo root, so a session in a fresh worktree migrates the
396
+ * MAIN checkout's legacy data (the only place it can be — worktrees share the
397
+ * store). Content is moved only after the copy is verified intact (see
398
+ * moveVerified); a failed move leaves the source in place.
399
+ */
400
+ export function migrateLegacyProjectData(
401
+ workspaceRoot: string,
402
+ identityKeySource: string,
403
+ centralDir: string,
404
+ options: MigrateOptions = {},
405
+ ): void {
406
+ const log = options.log ?? defaultMigrationLog
407
+ const candidates = new Set<string>([path.resolve(workspaceRoot)])
408
+ try {
409
+ candidates.add(path.resolve(identityKeySource))
410
+ } catch {
411
+ // keep just the workspace root
412
+ }
413
+ for (const candidate of candidates) {
414
+ const legacyRoot = path.join(candidate, LEGACY_WORKSPACE_CONFIG_DIR)
415
+ if (!fs.existsSync(legacyRoot)) {
416
+ continue
417
+ }
418
+ for (const { rel, dest } of LEGACY_MIGRATION_ENTRIES) {
419
+ const src = path.join(legacyRoot, rel)
420
+ if (!fs.existsSync(src)) {
421
+ continue
422
+ }
423
+ const target = path.join(centralDir, dest)
424
+ if (fs.existsSync(target)) {
425
+ continue
426
+ }
427
+ moveVerified(src, target, `${legacyRoot}/${rel}`, log)
428
+ }
429
+ // Drop the legacy dir only when nothing is left inside it.
430
+ try {
431
+ if (fs.readdirSync(legacyRoot).length === 0) {
432
+ fs.rmdirSync(legacyRoot)
433
+ }
434
+ } catch (err) {
435
+ // non-fatal: leftover empty legacy dir is harmless clutter, but
436
+ // issue #83 wants it visible rather than silently swallowed.
437
+ log(`could not remove legacy dir ${legacyRoot}: ${err instanceof Error ? err.message : String(err)}`)
438
+ }
439
+ }
440
+ }
441
+
442
+ /**
443
+ * Move `src` to `dest`, verifying the content landed intact before the source is
444
+ * removed. rename() when both are on the same filesystem (the common case — the
445
+ * checkpoints store and the central store are both under $HOME); on EXDEV (a
446
+ * real cross-device move of a multi-hundred-MB index) fall back to a recursive
447
+ * copy that compares sizes before unlinking the source. On any verification
448
+ * failure the source is left in place and the error propagates — an interrupted
449
+ * migration must never silently lose real index data.
450
+ */
451
+ export function moveVerified(src: string, dest: string, label: string, log: (msg: string) => void = defaultMigrationLog): void {
452
+ fs.mkdirSync(path.dirname(dest), { recursive: true })
453
+ try {
454
+ fs.renameSync(src, dest)
455
+ } catch (err) {
456
+ if ((err as NodeJS.ErrnoException).code !== "EXDEV") {
457
+ throw err
458
+ }
459
+ copyVerified(src, dest)
460
+ fs.rmSync(src, { recursive: true, force: true })
461
+ }
462
+ log(`migrated ${label} -> ${dest}`)
463
+ }
464
+
465
+ /** Recursive copy that verifies size equality file-by-file before returning. */
466
+ function copyVerified(src: string, dest: string): void {
467
+ const st = fs.lstatSync(src)
468
+ if (st.isDirectory()) {
469
+ fs.mkdirSync(dest, { recursive: true })
470
+ for (const entry of fs.readdirSync(src)) {
471
+ copyVerified(path.join(src, entry), path.join(dest, entry))
472
+ }
473
+ } else if (st.isSymbolicLink()) {
474
+ fs.symlinkSync(fs.readlinkSync(src), dest)
475
+ } else if (st.isFile()) {
476
+ fs.copyFileSync(src, dest)
477
+ if (fs.statSync(src).size !== fs.statSync(dest).size) {
478
+ throw new Error(`migration copy verification failed: size mismatch for '${src}'`)
479
+ }
480
+ }
481
+ }
482
+
483
+ /**
484
+ * True when the store root was overridden via $HEADLESSCODE_DATA_DIR (tests,
485
+ * per-machine scratch). Auto-migrations that touch REAL home-directory data
486
+ * (`~/.headlesscode/checkpoints`, `~/.roo/`) must skip entirely under an
487
+ * override — the override means "don't touch real data", and the explicit
488
+ * `headlesscode migrate` subcommand remains the human-triggered path for real
489
+ * machines.
490
+ */
491
+ export function isStoreOverridden(): boolean {
492
+ return (process.env.HEADLESSCODE_DATA_DIR ?? "").trim() !== ""
493
+ }
494
+
495
+ function storeOverridden(): boolean {
496
+ return isStoreOverridden()
497
+ }
498
+
499
+ let checkpointMigrationAttempted = false
500
+
501
+ /**
502
+ * Consolidate the checkpoint store: `~/.headlesscode/checkpoints` →
503
+ * `<root>/checkpoints`. The old location holds real, large data (2.5GB on the
504
+ * project owner's machine), so this is a verified MOVE, never a copy-and-orphan.
505
+ * Runs automatically once per process on the first `defaultCheckpointDir()`
506
+ * call and is also exposed via `headlesscode migrate` for explicit
507
+ * human-triggered runs. Safe when both locations already exist (the old one is
508
+ * left alone rather than silently merged).
509
+ */
510
+ export function migrateCheckpointStore(options: MigrateOptions & { legacyDir?: string } = {}): void {
511
+ const log = options.log ?? defaultMigrationLog
512
+ const legacyDir = options.legacyDir ?? path.join(os.homedir(), ".headlesscode", "checkpoints")
513
+ const targetDir = path.join(projectStoreRoot(), "checkpoints")
514
+ if (!fs.existsSync(legacyDir)) {
515
+ return
516
+ }
517
+ if (fs.existsSync(targetDir)) {
518
+ return
519
+ }
520
+ moveVerified(legacyDir, targetDir, legacyDir, log)
521
+ }
522
+
523
+ /**
524
+ * Auto-migrate `~/.headlesscode/checkpoints` once per process (called from
525
+ * defaultCheckpointDir). Skipped entirely when the store is overridden via
526
+ * $HEADLESSCODE_DATA_DIR (tests/scratch must never move REAL home data).
527
+ */
528
+ export function ensureCheckpointMigration(): void {
529
+ if (storeOverridden()) {
530
+ return
531
+ }
532
+ if (checkpointMigrationAttempted) {
533
+ return
534
+ }
535
+ checkpointMigrationAttempted = true
536
+ try {
537
+ migrateCheckpointStore()
538
+ } catch {
539
+ // Non-fatal: a failed migration leaves the legacy dir in place; the
540
+ // service still works against whichever location exists.
541
+ }
542
+ }
543
+
544
+ /**
545
+ * Migrate the global shared-instructions content out of `~/.roo/` (the
546
+ * Zoo-Code-branded location) into `~/.local/share/headlesscode/shared/`:
547
+ * `custom_modes.yaml` → `modes.yaml`, `rules/` → `rules/`,
548
+ * `rules-<mode>/` → `rules-<mode>/`. Only the LOOKUP PATH changes — the YAML
549
+ * format and the rules-directory-scanning convention are unchanged. Part of the
550
+ * same one-time migration family as the workspace data + checkpoints; exposed
551
+ * via `headlesscode migrate` and auto-run on first custom-modes load.
552
+ */
553
+ export function migrateSharedInstructions(options: MigrateOptions & { legacyRoot?: string } = {}): void {
554
+ const log = options.log ?? defaultMigrationLog
555
+ const legacyRoot = options.legacyRoot ?? path.join(os.homedir(), ".roo")
556
+ const targetRoot = sharedInstructionsRoot()
557
+ if (!fs.existsSync(legacyRoot)) {
558
+ return
559
+ }
560
+ const pairs: Array<[string, string]> = [["custom_modes.yaml", "modes.yaml"], ["rules", "rules"]]
561
+ let legacyEntries: string[] = []
562
+ try {
563
+ legacyEntries = fs.readdirSync(legacyRoot)
564
+ } catch {
565
+ return
566
+ }
567
+ for (const entry of legacyEntries) {
568
+ if (entry.startsWith("rules-")) {
569
+ pairs.push([entry, entry])
570
+ }
571
+ }
572
+ for (const [rel, dest] of pairs) {
573
+ const src = path.join(legacyRoot, rel)
574
+ if (!fs.existsSync(src)) {
575
+ continue
576
+ }
577
+ const target = path.join(targetRoot, dest)
578
+ if (fs.existsSync(target)) {
579
+ continue
580
+ }
581
+ moveVerified(src, target, path.join(legacyRoot, rel), log)
582
+ }
583
+ }
584
+
585
+ let sharedInstructionsMigrationAttempted = false
586
+
587
+ /**
588
+ * Auto-migrate `~/.roo/` shared content once per process (called from
589
+ * loadCustomModes). Skipped entirely when the store is overridden via
590
+ * $HEADLESSCODE_DATA_DIR (tests/scratch must never move REAL home data).
591
+ */
592
+ export function ensureSharedInstructionsMigration(): void {
593
+ if (storeOverridden()) {
594
+ return
595
+ }
596
+ if (sharedInstructionsMigrationAttempted) {
597
+ return
598
+ }
599
+ sharedInstructionsMigrationAttempted = true
600
+ try {
601
+ migrateSharedInstructions()
602
+ } catch {
603
+ // Non-fatal: a failed migration leaves ~/.roo/ in place.
604
+ }
605
+ }
606
+
607
+ /** Read the central settings.json (Part C: cross-project operational defaults). */
608
+ export interface CentralSettings {
609
+ embedding?: {
610
+ /** Default OpenRouter embedding model id (overridable per build via env/--model). */
611
+ model?: string
612
+ /** Provider pin for embedding requests (OpenRouter provider name, e.g. "DeepInfra"). */
613
+ provider?: string
614
+ /** Allow fallbacks to other providers when the pinned one is unavailable. */
615
+ allowFallbacks?: boolean
616
+ }
617
+ }
618
+
619
+ /** Loose-parse `<root>/settings.json`; {} when absent/malformed (never throws). */
620
+ export function loadCentralSettings(): CentralSettings {
621
+ const file = path.join(projectStoreRoot(), "settings.json")
622
+ let raw: string
623
+ try {
624
+ raw = fs.readFileSync(file, "utf-8")
625
+ } catch {
626
+ return {}
627
+ }
628
+ try {
629
+ const parsed = JSON.parse(raw) as Record<string, unknown>
630
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
631
+ return {}
632
+ }
633
+ const embedding = parsed.embedding
634
+ if (embedding === null || typeof embedding !== "object" || Array.isArray(embedding)) {
635
+ return { ...(parsed as CentralSettings) }
636
+ }
637
+ const out: CentralSettings = { ...(parsed as CentralSettings) }
638
+ const emb = embedding as Record<string, unknown>
639
+ out.embedding = {
640
+ ...(typeof emb.model === "string" && emb.model.trim() !== "" ? { model: emb.model } : {}),
641
+ ...(typeof emb.provider === "string" && emb.provider.trim() !== "" ? { provider: emb.provider } : {}),
642
+ ...(typeof emb.allowFallbacks === "boolean" ? { allowFallbacks: emb.allowFallbacks } : {}),
643
+ }
644
+ return out
645
+ } catch {
646
+ return {}
647
+ }
648
+ }