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.
- package/ATTRIBUTION.md +53 -0
- package/CODE_OF_CONDUCT.md +130 -0
- package/CONTRIBUTING.md +107 -0
- package/LICENSE +202 -0
- package/README.md +486 -0
- package/SECURITY.md +211 -0
- package/bin/headlesscode.mjs +83 -0
- package/package.json +63 -0
- package/shared/prompts/review-mode-prompt-short.md +93 -0
- package/shared/prompts/review-mode-prompt.md +281 -0
- package/shared/rules-code/rules.md +22 -0
- package/shared/stacks/cpp/rules.md +30 -0
- package/shared/stacks/fastapi/rules.md +30 -0
- package/shared/stacks/javascript/rules.md +37 -0
- package/shared/stacks/postgresql/rules.md +31 -0
- package/shared/stacks/python/rules.md +35 -0
- package/shared/stacks/react/rules.md +11 -0
- package/shared/stacks/typescript/rules.md +10 -0
- package/src/budget/budget.ts +221 -0
- package/src/budget/concurrency.ts +126 -0
- package/src/budget/cost.ts +309 -0
- package/src/budget/index.ts +8 -0
- package/src/checkpoints/cli.ts +256 -0
- package/src/checkpoints/service.ts +227 -0
- package/src/cli.ts +1535 -0
- package/src/cloud/docker-provider.ts +334 -0
- package/src/cloud/provider.ts +300 -0
- package/src/codeintel/call-graph.ts +78 -0
- package/src/codeintel/find-references.ts +123 -0
- package/src/codeintel/go-to-definition.ts +193 -0
- package/src/codeintel/handlers.ts +190 -0
- package/src/codeintel/import-graph.ts +173 -0
- package/src/codeintel/outline.ts +180 -0
- package/src/codeintel/position.ts +77 -0
- package/src/codeintel/program.ts +350 -0
- package/src/codeintel/rename-symbol.ts +213 -0
- package/src/codeintel/tools.ts +280 -0
- package/src/codemap/build.ts +135 -0
- package/src/codemap/cli.ts +190 -0
- package/src/codemap/extract.ts +339 -0
- package/src/codemap/files.ts +236 -0
- package/src/codemap/fingerprint.ts +65 -0
- package/src/codemap/flows.ts +62 -0
- package/src/codemap/html.ts +451 -0
- package/src/codemap/lock.ts +80 -0
- package/src/codemap/types.ts +101 -0
- package/src/codesearch/airunner-embedder.ts +185 -0
- package/src/codesearch/chunk.ts +339 -0
- package/src/codesearch/cli.ts +223 -0
- package/src/codesearch/embedder.ts +332 -0
- package/src/codesearch/files.ts +280 -0
- package/src/codesearch/index.ts +469 -0
- package/src/codesearch/ollama-embedder.ts +205 -0
- package/src/codesearch/search.ts +141 -0
- package/src/codesearch/types.ts +100 -0
- package/src/config/mode-models.ts +218 -0
- package/src/dashboard/aggregate.ts +364 -0
- package/src/dashboard/chat-thread.ts +141 -0
- package/src/dashboard/checkpoints.ts +124 -0
- package/src/dashboard/cli.ts +193 -0
- package/src/dashboard/codemap.ts +44 -0
- package/src/dashboard/files.ts +121 -0
- package/src/dashboard/page.ts +2803 -0
- package/src/dashboard/self-improvement-metrics.ts +282 -0
- package/src/dashboard/server.ts +1103 -0
- package/src/dashboard/session-launch.ts +310 -0
- package/src/dashboard/timeline.ts +273 -0
- package/src/dashboard/tool-exec.ts +107 -0
- package/src/dashboard/trend-cli.ts +141 -0
- package/src/dashboard/trend.ts +413 -0
- package/src/decision-proxy/cli.ts +261 -0
- package/src/decision-proxy/proxy.ts +569 -0
- package/src/deploy/gate-cli.ts +147 -0
- package/src/deploy/gate.ts +254 -0
- package/src/engine/condense.ts +512 -0
- package/src/engine/events.ts +428 -0
- package/src/engine/handoff.ts +71 -0
- package/src/engine/lazy-tools.ts +160 -0
- package/src/engine/local-explore.ts +653 -0
- package/src/engine/logger.ts +96 -0
- package/src/engine/loop.ts +5517 -0
- package/src/engine/parser.ts +347 -0
- package/src/engine/prompt.ts +860 -0
- package/src/engine/reports.ts +47 -0
- package/src/engine/stacks.ts +448 -0
- package/src/engine/types.ts +291 -0
- package/src/engine/usage.ts +186 -0
- package/src/github/app-auth.ts +161 -0
- package/src/github/cli.ts +448 -0
- package/src/github/installations.ts +133 -0
- package/src/github/pr.ts +321 -0
- package/src/github/provision.ts +118 -0
- package/src/github/push.ts +122 -0
- package/src/index-util.ts +50 -0
- package/src/index.ts +81 -0
- package/src/init/cli.ts +248 -0
- package/src/init/gitignore.ts +74 -0
- package/src/llm/ollama.ts +308 -0
- package/src/llm/openrouter.ts +868 -0
- package/src/llm/preflight.ts +367 -0
- package/src/llm/transcript-capture.ts +84 -0
- package/src/memory/embed.ts +110 -0
- package/src/memory/index.ts +22 -0
- package/src/memory/local.ts +259 -0
- package/src/memory/summarizer.ts +283 -0
- package/src/memory/types.ts +153 -0
- package/src/memory/uwuchat.ts +157 -0
- package/src/migrate/cli.ts +115 -0
- package/src/orchestrator/analyze-cli.ts +104 -0
- package/src/orchestrator/auto-split.ts +206 -0
- package/src/orchestrator/cleanup.ts +1003 -0
- package/src/orchestrator/cli.ts +3571 -0
- package/src/orchestrator/cost-estimate.ts +564 -0
- package/src/orchestrator/cost-history-cli.ts +242 -0
- package/src/orchestrator/cost-history.ts +397 -0
- package/src/orchestrator/git-sync.ts +250 -0
- package/src/orchestrator/index.ts +153 -0
- package/src/orchestrator/log-analysis.ts +0 -0
- package/src/orchestrator/merge-check.ts +108 -0
- package/src/orchestrator/pipeline.ts +411 -0
- package/src/orchestrator/resume.ts +1940 -0
- package/src/orchestrator/reviewer.ts +503 -0
- package/src/orchestrator/split.ts +296 -0
- package/src/orchestrator/state.ts +542 -0
- package/src/orchestrator/status.ts +697 -0
- package/src/orchestrator/verification-gate.ts +134 -0
- package/src/orchestrator/watch.ts +898 -0
- package/src/permissions/commands.ts +1083 -0
- package/src/permissions/config.ts +241 -0
- package/src/permissions/index.ts +12 -0
- package/src/permissions/protected-files.ts +96 -0
- package/src/permissions/store-protection.ts +272 -0
- package/src/project-store.ts +648 -0
- package/src/projects/cli.ts +382 -0
- package/src/qa/qa.ts +487 -0
- package/src/tools/browser/handler.ts +346 -0
- package/src/tools/browser/service.ts +406 -0
- package/src/tools/browser/smoke.ts +78 -0
- package/src/tools/browser/tool.ts +99 -0
- package/src/tools/executor.ts +2575 -0
- package/src/tools/language-detect.ts +183 -0
- package/src/tools/output-summarizer.ts +369 -0
- package/src/tools/run-tests.ts +302 -0
- package/src/tools/set-indentation-tool.ts +49 -0
- package/src/tools/test-selection.ts +160 -0
- package/src/vendor/tests/smoke.ts +103 -0
- package/src/vendor/zoo-code/VENDOR-NOTES.md +213 -0
- package/src/vendor/zoo-code/shim/anthropic.ts +71 -0
- package/src/vendor/zoo-code/shim/openai.d.ts +60 -0
- package/src/vendor/zoo-code/shim/os-name.ts +18 -0
- package/src/vendor/zoo-code/shim/strip-bom.ts +14 -0
- package/src/vendor/zoo-code/shim/vscode.ts +76 -0
- package/src/vendor/zoo-code/src/core/config/CustomModesManager.ts +1015 -0
- package/src/vendor/zoo-code/src/core/diff/strategies/multi-search-replace.ts +670 -0
- package/src/vendor/zoo-code/src/core/prompts/sections/capabilities.ts +46 -0
- package/src/vendor/zoo-code/src/core/prompts/sections/custom-instructions.ts +559 -0
- package/src/vendor/zoo-code/src/core/prompts/sections/index.ts +10 -0
- package/src/vendor/zoo-code/src/core/prompts/sections/markdown-formatting.ts +7 -0
- package/src/vendor/zoo-code/src/core/prompts/sections/modes.ts +35 -0
- package/src/vendor/zoo-code/src/core/prompts/sections/objective.ts +13 -0
- package/src/vendor/zoo-code/src/core/prompts/sections/rules.ts +95 -0
- package/src/vendor/zoo-code/src/core/prompts/sections/skills.ts +105 -0
- package/src/vendor/zoo-code/src/core/prompts/sections/system-info.ts +30 -0
- package/src/vendor/zoo-code/src/core/prompts/sections/tool-use-guidelines.ts +9 -0
- package/src/vendor/zoo-code/src/core/prompts/sections/tool-use.ts +7 -0
- package/src/vendor/zoo-code/src/core/prompts/system.ts +176 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/access_mcp_resource.ts +41 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/apply_diff.ts +40 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/apply_patch.ts +61 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/ask_followup_question.ts +62 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/attempt_completion.ts +33 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/codebase_search.ts +43 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/converters.ts +109 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/edit.ts +48 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/edit_file.ts +72 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/execute_command.ts +54 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/generate_image.ts +51 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/index.ts +75 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/list_files.ts +41 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/mcp_server.ts +75 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/new_task.ts +39 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/read_command_output.ts +81 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/read_file.ts +169 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/run_slash_command.ts +31 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/search_files.ts +50 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/search_replace.ts +51 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/skill.ts +33 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/switch_mode.ts +31 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/update_todo_list.ts +54 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/write_to_file.ts +40 -0
- package/src/vendor/zoo-code/src/core/prompts/types.ts +12 -0
- package/src/vendor/zoo-code/src/i18n/index.ts +19 -0
- package/src/vendor/zoo-code/src/integrations/misc/extract-text.ts +81 -0
- package/src/vendor/zoo-code/src/services/checkpoints/RepoPerTaskCheckpointService.ts +15 -0
- package/src/vendor/zoo-code/src/services/checkpoints/ShadowCheckpointService.ts +553 -0
- package/src/vendor/zoo-code/src/services/checkpoints/excludes.ts +212 -0
- package/src/vendor/zoo-code/src/services/checkpoints/index.ts +3 -0
- package/src/vendor/zoo-code/src/services/checkpoints/types.ts +35 -0
- package/src/vendor/zoo-code/src/services/code-index/manager.ts +19 -0
- package/src/vendor/zoo-code/src/services/mcp/McpHub.ts +36 -0
- package/src/vendor/zoo-code/src/services/roo-config/index.ts +441 -0
- package/src/vendor/zoo-code/src/services/search/file-search.ts +143 -0
- package/src/vendor/zoo-code/src/services/skills/SkillsManager.ts +20 -0
- package/src/vendor/zoo-code/src/shared/globalFileNames.ts +9 -0
- package/src/vendor/zoo-code/src/shared/language.ts +43 -0
- package/src/vendor/zoo-code/src/shared/modes.ts +257 -0
- package/src/vendor/zoo-code/src/shared/tools.ts +385 -0
- package/src/vendor/zoo-code/src/utils/fs.ts +39 -0
- package/src/vendor/zoo-code/src/utils/globalContext.ts +22 -0
- package/src/vendor/zoo-code/src/utils/json-schema.ts +16 -0
- package/src/vendor/zoo-code/src/utils/logging.ts +21 -0
- package/src/vendor/zoo-code/src/utils/mcp-name.ts +190 -0
- package/src/vendor/zoo-code/src/utils/object.ts +18 -0
- package/src/vendor/zoo-code/src/utils/path.ts +94 -0
- package/src/vendor/zoo-code/src/utils/shell.ts +376 -0
- package/src/vendor/zoo-code/src/utils/text-normalization.ts +99 -0
- package/src/vendor/zoo-code/types/global-settings.ts +19 -0
- package/src/vendor/zoo-code/types/index.ts +22 -0
- package/src/vendor/zoo-code/types/message.ts +375 -0
- package/src/vendor/zoo-code/types/mode.ts +241 -0
- package/src/vendor/zoo-code/types/todo.ts +19 -0
- package/src/vendor/zoo-code/types/tool-params.ts +116 -0
- package/src/vendor/zoo-code/types/tool.ts +67 -0
- package/src/vendor/zoo-code/types/vscode.ts +84 -0
- package/src/vision/describe.ts +242 -0
- package/src/vision/tool.ts +91 -0
- package/src/watcher/cli.ts +369 -0
- package/src/watcher/github.ts +304 -0
- package/src/watcher/index.ts +59 -0
- package/src/watcher/state.ts +254 -0
- package/src/watcher/watch.ts +562 -0
- 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
|
+
}
|