@amoree-code/ocean 0.0.0-stage → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (304) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +284 -2
  3. package/dist/application/auth/auth-orchestrator.js +77 -0
  4. package/dist/application/clients/clients.js +646 -0
  5. package/dist/application/clients/editors.js +398 -0
  6. package/dist/application/clients/hooks.js +335 -0
  7. package/dist/application/clients/native.js +410 -0
  8. package/dist/application/clients/opencode-plugin.js +158 -0
  9. package/dist/application/clients/registry.js +188 -0
  10. package/dist/application/gateway/webhook-gateway.js +137 -0
  11. package/dist/application/install/provider-installer.js +184 -0
  12. package/dist/application/integrations/charter-copies.js +76 -0
  13. package/dist/application/integrations/claude-vscode.js +106 -0
  14. package/dist/application/integrations/engine-entry-repair.js +105 -0
  15. package/dist/application/integrations/skill-filter.js +254 -0
  16. package/dist/application/layout/layout-apply.js +443 -0
  17. package/dist/application/layout/layout-plan.js +236 -0
  18. package/dist/application/layout/workspace-template.js +34 -0
  19. package/dist/application/onboarding/state.js +23 -0
  20. package/dist/application/onboarding/steps.js +249 -0
  21. package/dist/application/profiles/profile-distribution.js +53 -0
  22. package/dist/application/profiles/profile-suggestion.js +39 -0
  23. package/dist/application/profiles/profile-upgrade-service.js +208 -0
  24. package/dist/application/pull/history-index.js +476 -0
  25. package/dist/application/pull/project-match.js +132 -0
  26. package/dist/application/pull/pull.js +202 -0
  27. package/dist/application/pull/server-capture.js +307 -0
  28. package/dist/application/scheduler/local-scheduler.js +210 -0
  29. package/dist/composition/runtime.js +64 -0
  30. package/dist/engine-root.js +9 -0
  31. package/dist/infrastructure/filesystem/profile-loader.js +46 -0
  32. package/dist/infrastructure/filesystem/runtime-config-loader.js +29 -0
  33. package/dist/infrastructure/filesystem/skill-loader.js +84 -0
  34. package/dist/infrastructure/filesystem/workspace-capability.js +37 -0
  35. package/dist/infrastructure/observability/runtime-logger.js +16 -0
  36. package/dist/infrastructure/process/agent-tool-executor.js +99 -0
  37. package/dist/infrastructure/process/cli-process.js +49 -0
  38. package/dist/infrastructure/process/interactive-process.js +145 -0
  39. package/dist/infrastructure/process/process-supervisor.js +194 -0
  40. package/dist/infrastructure/process/service.js +252 -0
  41. package/dist/infrastructure/providers/browser-provider.js +2 -0
  42. package/dist/infrastructure/providers/claude-sdk.js +137 -0
  43. package/dist/infrastructure/providers/client-home.js +14 -0
  44. package/dist/infrastructure/providers/fake-browser-provider.js +146 -0
  45. package/dist/infrastructure/providers/ollama-embedder.js +74 -0
  46. package/dist/infrastructure/providers/openai-compatible.js +268 -0
  47. package/dist/infrastructure/providers/playwright-browser-provider.js +380 -0
  48. package/dist/infrastructure/providers/provider-capabilities.js +50 -0
  49. package/dist/infrastructure/providers/provider-invocation.js +184 -0
  50. package/dist/infrastructure/providers/provider-registry.js +77 -0
  51. package/dist/infrastructure/providers/providers.js +134 -0
  52. package/dist/infrastructure/wrappers/wrapper-manager.js +374 -0
  53. package/dist/interfaces/cli/auth-command.js +54 -0
  54. package/dist/interfaces/cli/browser-bridge-command.js +93 -0
  55. package/dist/interfaces/cli/browser-command.js +119 -0
  56. package/dist/interfaces/cli/capture-command.js +125 -0
  57. package/dist/interfaces/cli/client-test-command.js +133 -0
  58. package/dist/interfaces/cli/clients-command.js +129 -0
  59. package/dist/interfaces/cli/command-registry.js +1211 -0
  60. package/dist/interfaces/cli/context-command.js +172 -0
  61. package/dist/interfaces/cli/daily-command.js +131 -0
  62. package/dist/interfaces/cli/governance-command.js +55 -0
  63. package/dist/interfaces/cli/handoff-command.js +35 -0
  64. package/dist/interfaces/cli/help-command.js +207 -0
  65. package/dist/interfaces/cli/idea-command.js +47 -0
  66. package/dist/interfaces/cli/intercept-command.js +317 -0
  67. package/dist/interfaces/cli/layout-command.js +27 -0
  68. package/dist/interfaces/cli/market-command.js +52 -0
  69. package/dist/interfaces/cli/mcp-gateway-command.js +30 -0
  70. package/dist/interfaces/cli/memory-command.js +317 -0
  71. package/dist/interfaces/cli/migrate-command.js +26 -0
  72. package/dist/interfaces/cli/observe-command.js +78 -0
  73. package/dist/interfaces/cli/onboard-command.js +123 -0
  74. package/dist/interfaces/cli/operate-command.js +59 -0
  75. package/dist/interfaces/cli/profiles-command.js +70 -0
  76. package/dist/interfaces/cli/pull-command.js +54 -0
  77. package/dist/interfaces/cli/setup-command.js +143 -0
  78. package/dist/interfaces/cli/tasks-command.js +249 -0
  79. package/dist/main.js +49 -0
  80. package/dist/node-warnings.js +17 -0
  81. package/dist/version.js +8 -0
  82. package/market/mcp/chrome-devtools/ocean.capability.json +28 -0
  83. package/market/mcp/gateway/ocean.capability.json +21 -0
  84. package/market/mcp/obsidian/ocean.capability.json +21 -0
  85. package/market/mcp/ocean/ocean.capability.json +21 -0
  86. package/market/mcp/playwright/ocean.capability.json +23 -0
  87. package/node_modules/@ocean/brain/dist/brain/brain-eval.js +29 -0
  88. package/node_modules/@ocean/brain/dist/brain/brain-markdown.js +226 -0
  89. package/node_modules/@ocean/brain/dist/brain/brain-reindex.js +247 -0
  90. package/node_modules/@ocean/brain/dist/brain/brain-service.js +320 -0
  91. package/node_modules/@ocean/brain/dist/capture/inbox-sync.js +22 -0
  92. package/node_modules/@ocean/brain/dist/evidence/verification-report.js +13 -0
  93. package/node_modules/@ocean/brain/dist/infrastructure/brain/brain-index.js +285 -0
  94. package/node_modules/@ocean/brain/dist/memory/brain-dump.js +145 -0
  95. package/node_modules/@ocean/brain/dist/memory/daily-narrative.js +142 -0
  96. package/node_modules/@ocean/brain/dist/memory/index-sync.js +124 -0
  97. package/node_modules/@ocean/brain/dist/memory/lesson-activation.js +93 -0
  98. package/node_modules/@ocean/brain/dist/memory/lesson-learning.js +188 -0
  99. package/node_modules/@ocean/brain/dist/memory/lessons.js +321 -0
  100. package/node_modules/@ocean/brain/dist/memory/memory-audit.js +100 -0
  101. package/node_modules/@ocean/brain/dist/memory/memory-digest.js +49 -0
  102. package/node_modules/@ocean/brain/dist/memory/memory-harvest.js +345 -0
  103. package/node_modules/@ocean/brain/dist/memory/memory-paths.js +174 -0
  104. package/node_modules/@ocean/brain/dist/memory/memory-review.js +278 -0
  105. package/node_modules/@ocean/brain/dist/memory/memory-trash.js +152 -0
  106. package/node_modules/@ocean/brain/dist/memory/memory-write.js +216 -0
  107. package/node_modules/@ocean/brain/dist/memory/model-narrative.js +129 -0
  108. package/node_modules/@ocean/brain/dist/memory/profile-facts.js +103 -0
  109. package/node_modules/@ocean/brain/dist/memory/session-promotion.js +61 -0
  110. package/node_modules/@ocean/brain/dist/memory/session-summary.js +113 -0
  111. package/node_modules/@ocean/brain/dist/memory/signal-detection.js +66 -0
  112. package/node_modules/@ocean/brain/package.json +11 -0
  113. package/node_modules/@ocean/context/dist/context/context-compression.js +66 -0
  114. package/node_modules/@ocean/context/dist/context/context-cost.js +587 -0
  115. package/node_modules/@ocean/context/dist/context/context-ladder.js +363 -0
  116. package/node_modules/@ocean/context/dist/context/context-packet.js +213 -0
  117. package/node_modules/@ocean/context/dist/context/context-references.js +255 -0
  118. package/node_modules/@ocean/context/dist/context/intent-router.js +259 -0
  119. package/node_modules/@ocean/context/dist/context/resource-injection.js +36 -0
  120. package/node_modules/@ocean/context/package.json +11 -0
  121. package/node_modules/@ocean/core/dist/domain/brain/brain-record.js +22 -0
  122. package/node_modules/@ocean/core/dist/domain/brain/record-scope.js +22 -0
  123. package/node_modules/@ocean/core/dist/domain/capabilities/browser-contract.js +64 -0
  124. package/node_modules/@ocean/core/dist/domain/capabilities/capability-contract.js +16 -0
  125. package/node_modules/@ocean/core/dist/domain/capabilities/capability-manifest.js +72 -0
  126. package/node_modules/@ocean/core/dist/domain/context/context-validator.js +5 -0
  127. package/node_modules/@ocean/core/dist/domain/context/context.js +34 -0
  128. package/node_modules/@ocean/core/dist/domain/context/project-id.js +30 -0
  129. package/node_modules/@ocean/core/dist/domain/evidence/evidence.js +16 -0
  130. package/node_modules/@ocean/core/dist/domain/mcp/mcp-contract.js +36 -0
  131. package/node_modules/@ocean/core/dist/domain/mcp/memory-contract.js +41 -0
  132. package/node_modules/@ocean/core/dist/domain/ports/brain-index-port.js +7 -0
  133. package/node_modules/@ocean/core/dist/domain/ports/browser-port.js +8 -0
  134. package/node_modules/@ocean/core/dist/domain/ports/embedder-port.js +2 -0
  135. package/node_modules/@ocean/core/dist/domain/ports/platform-ports.js +2 -0
  136. package/node_modules/@ocean/core/dist/domain/ports/runtime-ports.js +2 -0
  137. package/node_modules/@ocean/core/dist/domain/ports/session-store-port.js +2 -0
  138. package/node_modules/@ocean/core/dist/domain/process/process-events.js +5 -0
  139. package/node_modules/@ocean/core/dist/domain/profiles/profile-distribution.js +19 -0
  140. package/node_modules/@ocean/core/dist/domain/profiles/profile-policy.js +34 -0
  141. package/node_modules/@ocean/core/dist/domain/profiles/profile-selection.js +73 -0
  142. package/node_modules/@ocean/core/dist/domain/profiles/profile-upgrade.js +123 -0
  143. package/node_modules/@ocean/core/dist/domain/profiles/profile-validator.js +5 -0
  144. package/node_modules/@ocean/core/dist/domain/profiles/profile.js +218 -0
  145. package/node_modules/@ocean/core/dist/domain/profiles/runtime-config.js +30 -0
  146. package/node_modules/@ocean/core/dist/domain/providers/provider-capability.js +28 -0
  147. package/node_modules/@ocean/core/dist/domain/providers/provider.js +2 -0
  148. package/node_modules/@ocean/core/dist/domain/redaction/redaction.js +86 -0
  149. package/node_modules/@ocean/core/dist/domain/runs/agent-tools.js +92 -0
  150. package/node_modules/@ocean/core/dist/domain/runs/execution-plan.js +71 -0
  151. package/node_modules/@ocean/core/dist/domain/runs/run-contract.js +38 -0
  152. package/node_modules/@ocean/core/dist/domain/sessions/entry-contract.js +31 -0
  153. package/node_modules/@ocean/core/dist/domain/sessions/handoff.js +78 -0
  154. package/node_modules/@ocean/core/dist/domain/sessions/session.js +58 -0
  155. package/node_modules/@ocean/core/dist/domain/skills/default-skills.js +29 -0
  156. package/node_modules/@ocean/core/dist/domain/skills/skill-validator.js +5 -0
  157. package/node_modules/@ocean/core/dist/domain/skills/skill.js +11 -0
  158. package/node_modules/@ocean/core/dist/domain/tasks/task-id.js +85 -0
  159. package/node_modules/@ocean/core/dist/shared/fs-utils.js +212 -0
  160. package/node_modules/@ocean/core/dist/shared/local-date.js +16 -0
  161. package/node_modules/@ocean/core/dist/shared/paths.js +324 -0
  162. package/node_modules/@ocean/core/dist/shared/project-resolution.js +1065 -0
  163. package/node_modules/@ocean/core/dist/shared/task-reference.js +133 -0
  164. package/node_modules/@ocean/core/package.json +14 -0
  165. package/node_modules/@ocean/loop/dist/handoff/handoff-service.js +133 -0
  166. package/node_modules/@ocean/loop/dist/hooks/lifecycle-hooks.js +15 -0
  167. package/node_modules/@ocean/loop/dist/hooks/session-end-hook.js +183 -0
  168. package/node_modules/@ocean/loop/dist/hooks/session-start-hook.js +237 -0
  169. package/node_modules/@ocean/loop/dist/infrastructure/persistence/session-store.js +690 -0
  170. package/node_modules/@ocean/loop/dist/infrastructure/persistence/session-validator.js +5 -0
  171. package/node_modules/@ocean/loop/dist/loops/iteration-graph.js +147 -0
  172. package/node_modules/@ocean/loop/dist/loops/task-loop-record.js +2 -0
  173. package/node_modules/@ocean/loop/dist/loops/task-loop.js +281 -0
  174. package/node_modules/@ocean/loop/dist/operations/memory-knowledge-operations.js +263 -0
  175. package/node_modules/@ocean/loop/dist/operations/operation-contract.js +227 -0
  176. package/node_modules/@ocean/loop/dist/operations/operation-scope.js +28 -0
  177. package/node_modules/@ocean/loop/dist/operations/project-operations.js +93 -0
  178. package/node_modules/@ocean/loop/dist/operations/record-io.js +65 -0
  179. package/node_modules/@ocean/loop/dist/operations/record-operations.js +45 -0
  180. package/node_modules/@ocean/loop/dist/operations/task-linker.js +40 -0
  181. package/node_modules/@ocean/loop/dist/operations/task-operations.js +279 -0
  182. package/node_modules/@ocean/loop/dist/operations/write-guard.js +157 -0
  183. package/node_modules/@ocean/loop/dist/runs/prompt-assembly.js +112 -0
  184. package/node_modules/@ocean/loop/dist/runs/run-agent.js +425 -0
  185. package/node_modules/@ocean/loop/dist/runs/run-authorization.js +24 -0
  186. package/node_modules/@ocean/loop/dist/runs/tool-loop.js +173 -0
  187. package/node_modules/@ocean/loop/dist/sessions/session-closeout.js +150 -0
  188. package/node_modules/@ocean/loop/dist/sessions/session-pointer.js +133 -0
  189. package/node_modules/@ocean/loop/dist/sessions/session-projects.js +26 -0
  190. package/node_modules/@ocean/loop/dist/sessions/session-retention.js +132 -0
  191. package/node_modules/@ocean/loop/dist/tasks/archive-tasks.js +210 -0
  192. package/node_modules/@ocean/loop/dist/tasks/checkpoint-task.js +31 -0
  193. package/node_modules/@ocean/loop/dist/tasks/claim-task.js +126 -0
  194. package/node_modules/@ocean/loop/dist/tasks/list-tasks.js +113 -0
  195. package/node_modules/@ocean/loop/dist/tasks/new-task.js +42 -0
  196. package/node_modules/@ocean/loop/dist/tasks/renumber-tasks.js +134 -0
  197. package/node_modules/@ocean/loop/dist/tasks/verification-readiness.js +78 -0
  198. package/node_modules/@ocean/loop/dist/tasks/verify-task.js +234 -0
  199. package/node_modules/@ocean/loop/package.json +11 -0
  200. package/node_modules/@ocean/market/dist/adapters/mcp-adapter.js +64 -0
  201. package/node_modules/@ocean/market/dist/adapters/owned-directory.js +89 -0
  202. package/node_modules/@ocean/market/dist/adapters/plugin-adapter.js +39 -0
  203. package/node_modules/@ocean/market/dist/adapters/skill-adapter.js +32 -0
  204. package/node_modules/@ocean/market/dist/capability/adapter.js +2 -0
  205. package/node_modules/@ocean/market/dist/capability/catalog.js +63 -0
  206. package/node_modules/@ocean/market/dist/capability/content-digest.js +40 -0
  207. package/node_modules/@ocean/market/dist/capability/registry.js +81 -0
  208. package/node_modules/@ocean/market/dist/install/default-marketplace.js +47 -0
  209. package/node_modules/@ocean/market/dist/install/lock.js +34 -0
  210. package/node_modules/@ocean/market/dist/install/marketplace-service.js +165 -0
  211. package/node_modules/@ocean/market/dist/mcp/browser-bridge.js +263 -0
  212. package/node_modules/@ocean/market/dist/mcp/mcp-connection.js +67 -0
  213. package/node_modules/@ocean/market/dist/mcp/mcp-gateway.js +317 -0
  214. package/node_modules/@ocean/market/dist/mcp/mcp-launch.js +102 -0
  215. package/node_modules/@ocean/market/dist/mcp/mcp-runner.js +32 -0
  216. package/node_modules/@ocean/market/dist/security/description-scan.js +45 -0
  217. package/node_modules/@ocean/market/dist/security/permissions.js +57 -0
  218. package/node_modules/@ocean/market/dist/security/sandbox.js +74 -0
  219. package/node_modules/@ocean/market/dist/skills/core-skill-sync.js +113 -0
  220. package/node_modules/@ocean/market/dist/skills/signal-detection.js +84 -0
  221. package/node_modules/@ocean/market/dist/skills/skill-curation.js +123 -0
  222. package/node_modules/@ocean/market/dist/skills/skill-hub.js +220 -0
  223. package/node_modules/@ocean/market/dist/skills/task-observer.js +165 -0
  224. package/node_modules/@ocean/market/package.json +11 -0
  225. package/node_modules/@ocean/services/dist/browser/browser-service.js +124 -0
  226. package/node_modules/@ocean/services/dist/browser/browser-session.js +287 -0
  227. package/node_modules/@ocean/services/dist/browser/browser-task-runner.js +70 -0
  228. package/node_modules/@ocean/services/dist/doctor/workspace-doctor.js +547 -0
  229. package/node_modules/@ocean/services/dist/infrastructure/mcp/call-ledger.js +114 -0
  230. package/node_modules/@ocean/services/dist/infrastructure/mcp/mcp-client.js +162 -0
  231. package/node_modules/@ocean/services/dist/infrastructure/mcp/obsidian-server.js +251 -0
  232. package/node_modules/@ocean/services/dist/infrastructure/mcp/ocean-server.js +880 -0
  233. package/node_modules/@ocean/services/dist/infrastructure/mcp/server-version.js +20 -0
  234. package/node_modules/@ocean/services/dist/infrastructure/mcp/tool-registry.js +29 -0
  235. package/node_modules/@ocean/services/dist/obsidian/conflict-log.js +68 -0
  236. package/node_modules/@ocean/services/dist/obsidian/inbox-promotion.js +80 -0
  237. package/node_modules/@ocean/services/dist/obsidian/vault-discovery.js +113 -0
  238. package/node_modules/@ocean/services/dist/obsidian/vault-ingestion.js +77 -0
  239. package/node_modules/@ocean/services/dist/obsidian/vault-sync.js +73 -0
  240. package/node_modules/@ocean/services/dist/obsidian/vault-writer.js +75 -0
  241. package/node_modules/@ocean/services/package.json +11 -0
  242. package/ocean-marketplace.json +415 -0
  243. package/package.json +67 -4
  244. package/skills/core/business-logic/SKILL.md +22 -0
  245. package/skills/core/catch-up/SKILL.md +17 -0
  246. package/skills/core/core-thinking/SKILL.md +11 -0
  247. package/skills/core/design-thinking/SKILL.md +19 -0
  248. package/skills/core/graft/SKILL.md +156 -0
  249. package/skills/core/loop/SKILL.md +20 -0
  250. package/skills/core/session-handoff/SKILL.md +15 -0
  251. package/skills/core/use-browser/SKILL.md +69 -0
  252. package/skills/core/verification/SKILL.md +11 -0
  253. package/skills/index.json +56 -0
  254. package/templates/README.md +4 -0
  255. package/templates/profiles/default.json +51 -0
  256. package/templates/profiles/developer.json +51 -0
  257. package/templates/profiles/reviewer.json +51 -0
  258. package/templates/profiles/strategist.json +51 -0
  259. package/templates/workspace/00-inbox/INBOX.md +7 -0
  260. package/templates/workspace/00-inbox/candidates/.gitkeep +0 -0
  261. package/templates/workspace/00-inbox/native/.gitkeep +0 -0
  262. package/templates/workspace/01-daily/.gitkeep +0 -0
  263. package/templates/workspace/02-personal/CONFLICTS.md +7 -0
  264. package/templates/workspace/02-personal/MEMORY.md +15 -0
  265. package/templates/workspace/02-personal/goals/.gitkeep +0 -0
  266. package/templates/workspace/02-personal/identity/.gitkeep +0 -0
  267. package/templates/workspace/02-personal/preferences/.gitkeep +0 -0
  268. package/templates/workspace/03-professional/.gitkeep +0 -0
  269. package/templates/workspace/04-projects/PROJECTS.md +5 -0
  270. package/templates/workspace/04-projects/_template/docs/.gitkeep +0 -0
  271. package/templates/workspace/04-projects/_template/memory/.gitkeep +0 -0
  272. package/templates/workspace/04-projects/_template/tasks/.gitkeep +0 -0
  273. package/templates/workspace/05-knowledge/KNOWLEDGE.md +8 -0
  274. package/templates/workspace/05-knowledge/decisions/.gitkeep +0 -0
  275. package/templates/workspace/05-knowledge/lessons/.gitkeep +0 -0
  276. package/templates/workspace/06-templates/TEMPLATES.md +25 -0
  277. package/templates/workspace/06-templates/decision.md +8 -0
  278. package/templates/workspace/06-templates/intelligence.md +32 -0
  279. package/templates/workspace/06-templates/project-claude.md +48 -0
  280. package/templates/workspace/06-templates/session.md +38 -0
  281. package/templates/workspace/06-templates/task.md +42 -0
  282. package/templates/workspace/MANIFEST.json +51 -0
  283. package/templates/workspace/bridge/config/CONFIG.md +21 -0
  284. package/templates/workspace/bridge/config/runtime.json +4 -0
  285. package/templates/workspace/charter/core.md +104 -0
  286. package/templates/workspace/charter/policies/README.md +31 -0
  287. package/templates/workspace/charter/policies/claude-code-context.md +90 -0
  288. package/templates/workspace/charter/policies/concise-output.md +29 -0
  289. package/templates/workspace/charter/policies/context.md +91 -0
  290. package/templates/workspace/charter/policies/contracts/task.md +189 -0
  291. package/templates/workspace/charter/policies/git.md +60 -0
  292. package/templates/workspace/charter/policies/graph.md +47 -0
  293. package/templates/workspace/charter/policies/knowledge.md +33 -0
  294. package/templates/workspace/charter/policies/lifecycle.md +119 -0
  295. package/templates/workspace/charter/policies/memory.md +67 -0
  296. package/templates/workspace/charter/policies/minimal-change.md +43 -0
  297. package/templates/workspace/charter/policies/models.md +57 -0
  298. package/templates/workspace/charter/policies/privacy-terms.txt +3 -0
  299. package/templates/workspace/charter/policies/response.md +39 -0
  300. package/templates/workspace/charter/policies/skill-observer.md +44 -0
  301. package/templates/workspace/charter/policies/stack.md +23 -0
  302. package/templates/workspace/charter/policies/task.md +139 -0
  303. package/templates/workspace/charter/policies/verification.md +29 -0
  304. package/templates/workspace/sessions/.gitkeep +0 -0
@@ -0,0 +1,1065 @@
1
+ import { execFile } from "node:child_process";
2
+ import { existsSync, readFileSync, realpathSync, statSync, } from "node:fs";
3
+ import { mkdir, readdir, readFile, realpath, rename, rmdir, writeFile, } from "node:fs/promises";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import { promisify } from "node:util";
7
+ import { derivePrefix, formatTaskId, isValidPrefix, LEGACY_PREFIX, } from "../domain/tasks/task-id.js";
8
+ import { atomicWrite, registryFile, withFileLock } from "./fs-utils.js";
9
+ import { BRAIN_RECORD_DIRS, CHARTER_DIR, isWorkspaceProject, listProjectDirs, oceanEnv, oceanPath, oceanRoot, PROJECTS_DIR, projectDir, projectFolder, SYSTEM_DIR, WORKSPACE_PROJECT_ID, } from "./paths.js";
10
+ export function projectConfirmationQuestion(resolution) {
11
+ if (resolution.status === "bound")
12
+ return null;
13
+ if (resolution.status === "ambiguous")
14
+ return "Which Ocean project should this request use? Specify the project name or binding path.";
15
+ return "Which Ocean project should this request use? Provide the project name and path to bind it.";
16
+ }
17
+ function bindingsFile() {
18
+ return registryFile("project-bindings.json");
19
+ }
20
+ export function findGitRoot(startDir) {
21
+ let dir = path.resolve(startDir);
22
+ while (true) {
23
+ if (existsSync(path.join(dir, ".git")))
24
+ return dir;
25
+ const parent = path.dirname(dir);
26
+ if (parent === dir)
27
+ return null;
28
+ dir = parent;
29
+ }
30
+ }
31
+ // A linked worktree (`git worktree add`) has a `.git` file pointing into the main repo's
32
+ // `.git/worktrees/<name>`; returns that main repo, or null for a normal repo or a submodule.
33
+ export function linkedWorktreeMain(gitRoot) {
34
+ try {
35
+ const dotGit = path.join(gitRoot, ".git");
36
+ if (!statSync(dotGit).isFile())
37
+ return null;
38
+ const pointer = readFileSync(dotGit, "utf8").match(/^gitdir:\s*(.+)$/m)?.[1];
39
+ if (!pointer)
40
+ return null;
41
+ const gitdir = path.resolve(gitRoot, pointer.trim());
42
+ const match = gitdir.match(/^(.*)[\\/]\.git[\\/]worktrees[\\/][^\\/]+$/);
43
+ return match ? match[1] : null;
44
+ }
45
+ catch {
46
+ return null;
47
+ }
48
+ }
49
+ const execFileAsync = promisify(execFile);
50
+ // `git status --short` lines for the repo containing `directory`; [] when it is not a repo or
51
+ // git fails. --no-optional-locks: sessions share working trees, and a status that refreshes the
52
+ // index would take index.lock and fail another session's concurrent add/commit (T-257).
53
+ export async function gitChangedFiles(directory, timeout = 1_000) {
54
+ try {
55
+ const { stdout } = await execFileAsync("git", ["--no-optional-locks", "-C", directory, "status", "--short"], { timeout, maxBuffer: 1024 * 1024 });
56
+ return stdout
57
+ .split("\n")
58
+ .map((line) => line.trim())
59
+ .filter(Boolean);
60
+ }
61
+ catch {
62
+ return [];
63
+ }
64
+ }
65
+ export const MAX_PROJECT_RULES = 10;
66
+ export const MAX_PROJECT_RULE_BYTES = 200;
67
+ export const MAX_PROJECT_VERIFY = 5;
68
+ export const MAX_PROJECT_VERIFY_BYTES = 300;
69
+ export const MAX_PROJECT_TAGS = 10;
70
+ export const MAX_PROJECT_FORMER_PATHS = 20;
71
+ const MANIFEST_FILE = "project.json";
72
+ export function expandHome(value) {
73
+ if (value === "~")
74
+ return os.homedir();
75
+ if (value.startsWith("~/") || value.startsWith("~\\"))
76
+ return path.join(os.homedir(), value.slice(2));
77
+ return value;
78
+ }
79
+ export function portablePath(value) {
80
+ const home = os.homedir();
81
+ const resolved = path.resolve(value);
82
+ if (resolved === home)
83
+ return "~";
84
+ if (resolved.startsWith(`${home}${path.sep}`))
85
+ return `~/${path.relative(home, resolved).split(path.sep).join("/")}`;
86
+ return resolved;
87
+ }
88
+ import { isProjectCategory, isValidProjectId, PROJECT_CATEGORIES, PROJECT_CATEGORY_FOLDERS, UNCATEGORIZED, } from "../domain/context/project-id.js";
89
+ export { isValidProjectId, PROJECT_CATEGORIES, UNCATEGORIZED, };
90
+ // One spelling per directory: symlinks resolved (macOS /var is /private/var, and process.cwd()
91
+ // returns the resolved form), so a bound path and the cwd of a session inside it compare equal.
92
+ // A path that does not exist (a repo not cloned on this machine yet) is kept as written.
93
+ export function canonicalPath(value) {
94
+ const resolved = path.resolve(expandHome(value));
95
+ try {
96
+ return realpathSync(resolved);
97
+ }
98
+ catch {
99
+ return resolved;
100
+ }
101
+ }
102
+ function projectsRoot() {
103
+ return oceanPath(PROJECTS_DIR);
104
+ }
105
+ // The category folder a project folder sits in, or undefined while it is flat.
106
+ function folderCategory(dir) {
107
+ const [first, second] = path.posix.relative(PROJECTS_DIR, dir).split("/");
108
+ return second && PROJECT_CATEGORY_FOLDERS.includes(first) ? first : undefined;
109
+ }
110
+ // Control characters (tab aside) and Unicode line breaks: a rule or command is one plain line.
111
+ const LINE_BREAKING = /[\p{Cc}\u2028\u2029]/u;
112
+ export function hasControlCharacters(value) {
113
+ return LINE_BREAKING.test(value.replace(/\t/g, " "));
114
+ }
115
+ function lineList(field, value, maxEntries, maxBytes) {
116
+ if (value === undefined)
117
+ return [];
118
+ if (!Array.isArray(value))
119
+ throw new Error(`${field} must be a list of strings`);
120
+ if (value.length > maxEntries)
121
+ throw new Error(`${field} holds at most ${maxEntries} entries`);
122
+ return value.map((entry, index) => {
123
+ if (typeof entry !== "string" || !entry.trim())
124
+ throw new Error(`${field}[${index}] must be a non-empty string`);
125
+ const line = entry.trim();
126
+ if (hasControlCharacters(line))
127
+ throw new Error(`${field}[${index}] must be a single line without control characters`);
128
+ if (Buffer.byteLength(line, "utf8") > maxBytes)
129
+ throw new Error(`${field}[${index}] exceeds ${maxBytes} bytes`);
130
+ return line;
131
+ });
132
+ }
133
+ // The validated lists, or an Error naming the field.
134
+ export function projectRules(manifest) {
135
+ return lineList("rules", manifest.rules, MAX_PROJECT_RULES, MAX_PROJECT_RULE_BYTES);
136
+ }
137
+ export function projectVerify(manifest) {
138
+ return lineList("verify", manifest.verify, MAX_PROJECT_VERIFY, MAX_PROJECT_VERIFY_BYTES);
139
+ }
140
+ export function projectTags(manifest) {
141
+ return lineList("tags", manifest.tags, MAX_PROJECT_TAGS, 40).map((tag) => tag.toLowerCase());
142
+ }
143
+ // The category, undefined when none is set, or an Error naming the allowed values.
144
+ export function projectCategory(manifest) {
145
+ if (manifest.category === undefined)
146
+ return undefined;
147
+ if (!isProjectCategory(manifest.category))
148
+ throw new Error(`category must be one of ${PROJECT_CATEGORIES.join(", ")}`);
149
+ return manifest.category;
150
+ }
151
+ export function projectFormerPaths(manifest) {
152
+ return lineList("formerPaths", manifest.formerPaths, MAX_PROJECT_FORMER_PATHS, 500);
153
+ }
154
+ // `folder` is the project folder's name, `category` the category folder it sits in, if any.
155
+ function parseManifest(folder, raw, category) {
156
+ const parsed = JSON.parse(raw);
157
+ if (!parsed ||
158
+ typeof parsed !== "object" ||
159
+ !Array.isArray(parsed.paths) ||
160
+ parsed.paths.some((entry) => typeof entry !== "string" || !entry.trim()))
161
+ throw new Error("paths must be a list of non-empty strings");
162
+ if (parsed.prefix !== undefined &&
163
+ (typeof parsed.prefix !== "string" ||
164
+ parsed.prefix === LEGACY_PREFIX ||
165
+ !isValidPrefix(parsed.prefix)))
166
+ throw new Error(`prefix '${String(parsed.prefix)}' is not a valid task prefix`);
167
+ if (parsed.nextTask !== undefined &&
168
+ (!Number.isInteger(parsed.nextTask) || parsed.nextTask < 1))
169
+ throw new Error("nextTask must be a positive integer");
170
+ // The folder name is the id; a manifest cannot claim another project's identity.
171
+ return {
172
+ id: folder,
173
+ name: typeof parsed.name === "string" && parsed.name ? parsed.name : folder,
174
+ paths: parsed.paths,
175
+ created: typeof parsed.created === "string" ? parsed.created : "",
176
+ autoRegistered: parsed.autoRegistered === true,
177
+ ...(parsed.prefix !== undefined ? { prefix: parsed.prefix } : {}),
178
+ ...(parsed.nextTask !== undefined ? { nextTask: parsed.nextTask } : {}),
179
+ ...(parsed.rules !== undefined ? { rules: parsed.rules } : {}),
180
+ ...(parsed.verify !== undefined ? { verify: parsed.verify } : {}),
181
+ ...(parsed.tags !== undefined ? { tags: parsed.tags } : {}),
182
+ ...(parsed.formerPaths !== undefined
183
+ ? { formerPaths: parsed.formerPaths }
184
+ : {}),
185
+ ...(category === UNCATEGORIZED
186
+ ? {}
187
+ : category !== undefined
188
+ ? { category }
189
+ : parsed.category !== undefined
190
+ ? { category: parsed.category }
191
+ : {}),
192
+ };
193
+ }
194
+ // A broken project.json takes only its own project out of resolution: resolveProject runs in
195
+ // every session's start hook, so one bad file must not break every project. The errors are
196
+ // returned for `ocean project list` to report.
197
+ export async function scanProjectManifests() {
198
+ const scan = { manifests: [], errors: [] };
199
+ const seen = new Set();
200
+ for (const { id, category, dir } of listProjectDirs()) {
201
+ const file = oceanPath(dir, MANIFEST_FILE);
202
+ // One id with project.json in two folders: the first, where projectDir looks first, is the
203
+ // project. A folder without project.json claims nothing.
204
+ if (seen.has(id)) {
205
+ if (existsSync(file))
206
+ scan.errors.push({
207
+ file,
208
+ error: `project id '${id}' is already used by another category folder`,
209
+ });
210
+ continue;
211
+ }
212
+ try {
213
+ scan.manifests.push(parseManifest(id, await readFile(file, "utf8"), category));
214
+ seen.add(id);
215
+ }
216
+ catch (error) {
217
+ if (error.code === "ENOENT")
218
+ continue;
219
+ seen.add(id);
220
+ scan.errors.push({
221
+ file,
222
+ error: error instanceof Error ? error.message : String(error),
223
+ });
224
+ }
225
+ }
226
+ return scan;
227
+ }
228
+ // One project's manifest, or null when it has none. A manifest that does not parse throws:
229
+ // callers that must hold a project's rules fail closed instead of dropping them.
230
+ export async function readProjectManifest(id, root = oceanRoot()) {
231
+ if (!isValidProjectId(id))
232
+ return null;
233
+ const dir = projectDir(id, root);
234
+ try {
235
+ return parseManifest(projectFolder(id), await readFile(path.join(root, dir, MANIFEST_FILE), "utf8"), folderCategory(dir));
236
+ }
237
+ catch (error) {
238
+ if (error.code === "ENOENT")
239
+ return null;
240
+ throw error;
241
+ }
242
+ }
243
+ export async function listProjectManifests() {
244
+ return (await scanProjectManifests()).manifests;
245
+ }
246
+ export async function writeManifest(manifest) {
247
+ const dir = projectDir(manifest.id);
248
+ const file = oceanPath(dir, MANIFEST_FILE);
249
+ // In a category folder the folder is the category; only a flat project keeps the field.
250
+ const { category, ...rest } = manifest;
251
+ const record = folderCategory(dir) === undefined && category !== undefined
252
+ ? manifest
253
+ : rest;
254
+ await mkdir(path.dirname(file), { recursive: true });
255
+ await atomicWrite(file, `${JSON.stringify(record, null, 2)}\n`);
256
+ }
257
+ // Bindings recorded before T-256, read from the flat registry file. Kept as a fallback until
258
+ // `ocean project migrate --write` folds them into manifests, and rewritten as a cache after
259
+ // every manifest change for anything that still reads the file. Entries the cache holds for
260
+ // manifests are skipped, so a deleted project folder does not come back through it.
261
+ // A corrupt file throws: resolution fails closed (no project, no task references) and writers
262
+ // never overwrite what they cannot read. The session-start hook catches it on its own.
263
+ async function listLegacyBindings() {
264
+ try {
265
+ const raw = await readFile(bindingsFile(), "utf8");
266
+ const parsed = JSON.parse(raw);
267
+ if (!Array.isArray(parsed?.bindings ?? []))
268
+ throw new Error(`${bindingsFile()}: bindings is not a list`);
269
+ return (parsed.bindings ?? []).filter((binding) => !binding.manifest);
270
+ }
271
+ catch (error) {
272
+ if (error.code === "ENOENT")
273
+ return [];
274
+ throw error;
275
+ }
276
+ }
277
+ // gitRoot is left null here: resolution matches on paths, and walking up from every path of
278
+ // every project on each session start would cost a filesystem walk per path for nothing.
279
+ function manifestBindings(manifests) {
280
+ return manifests.flatMap((manifest) => manifest.paths.map((stored) => ({
281
+ id: manifest.id,
282
+ name: manifest.name,
283
+ path: canonicalPath(stored),
284
+ gitRoot: null,
285
+ boundAt: manifest.created,
286
+ manifest: true,
287
+ })));
288
+ }
289
+ function combineBindings(manifests, legacy) {
290
+ const fromManifests = manifestBindings(manifests);
291
+ const claimed = new Set(fromManifests.map((binding) => binding.path));
292
+ return [
293
+ ...fromManifests,
294
+ ...legacy
295
+ .map((binding) => ({ ...binding, path: canonicalPath(binding.path) }))
296
+ .filter((binding) => !claimed.has(binding.path)),
297
+ ];
298
+ }
299
+ export async function listProjectBindings() {
300
+ return combineBindings(await listProjectManifests(), await listLegacyBindings());
301
+ }
302
+ // The cache keeps every legacy entry, including ones a manifest now claims, so deleting that
303
+ // manifest later never loses the old binding.
304
+ function cacheBindings(manifests, legacy) {
305
+ return [...manifestBindings(manifests), ...legacy];
306
+ }
307
+ async function saveProjectBindings(bindings) {
308
+ const file = bindingsFile();
309
+ await mkdir(path.dirname(file), { recursive: true });
310
+ await atomicWrite(file, `${JSON.stringify({ version: 1, bindings }, null, 2)}\n`);
311
+ }
312
+ // One lock for every manifest/cache write: bindings are checked across all projects (a path
313
+ // may belong to one project only), so per-project locks would let two writers both pass.
314
+ export function withProjectsLock(run) {
315
+ return withFileLock(bindingsFile(), run);
316
+ }
317
+ // A manifest for a project seen for the first time. A folder with no tasks gets its own task
318
+ // prefix and counter; one that already holds tasks stays on the legacy T namespace until
319
+ // `ocean tasks renumber`. The workspace project is always T.
320
+ async function newManifest(id, fields, others) {
321
+ const manifest = { id, ...fields };
322
+ if (isWorkspaceProject(id))
323
+ return manifest;
324
+ const tasks = await readdir(oceanPath(projectDir(id), "tasks")).catch(() => []);
325
+ if (tasks.some((entry) => !entry.startsWith(".")))
326
+ return manifest;
327
+ const taken = others.flatMap((other) => (other.prefix ? [other.prefix] : []));
328
+ return { ...manifest, prefix: derivePrefix(id, taken), nextTask: 1 };
329
+ }
330
+ export async function knownTaskPrefixes() {
331
+ const prefixes = (await listProjectManifests()).flatMap((manifest) => manifest.prefix ? [manifest.prefix] : []);
332
+ return [LEGACY_PREFIX, ...new Set(prefixes)];
333
+ }
334
+ // The project that owns a non-legacy prefix; null for T (the caller's own project) or a prefix
335
+ // nobody uses. Two manifests claiming one prefix is a configuration error, never a guess.
336
+ export async function projectForPrefix(prefix) {
337
+ if (prefix === LEGACY_PREFIX)
338
+ return null;
339
+ const owners = (await listProjectManifests())
340
+ .filter((manifest) => manifest.prefix === prefix)
341
+ .map((manifest) => manifest.id);
342
+ if (owners.length > 1)
343
+ throw new Error(`prefix ${prefix} is claimed by ${owners.join(", ")}; fix project.json`);
344
+ return owners[0] ?? null;
345
+ }
346
+ const TASK_NUMBER_SCAN_DEPTH = 2;
347
+ // Highest <prefix>-<n> folder under every project's tasks/, live and archived. Archives are
348
+ // tasks/archive/<namespace>/<id> (or flat archive/<id>), so descend archive/ two levels.
349
+ // Every project is scanned because the legacy T sequence was shared by all of them.
350
+ export async function highestTaskNumber(prefix) {
351
+ const pattern = new RegExp(`^${prefix}-(\\d+)$`);
352
+ let highest = 0;
353
+ const visit = async (directory, depth) => {
354
+ let entries;
355
+ try {
356
+ entries = await readdir(directory, { withFileTypes: true });
357
+ }
358
+ catch {
359
+ return;
360
+ }
361
+ for (const entry of entries) {
362
+ if (!entry.isDirectory())
363
+ continue;
364
+ const match = pattern.exec(entry.name);
365
+ if (match)
366
+ highest = Math.max(highest, Number(match[1]));
367
+ else if (depth < TASK_NUMBER_SCAN_DEPTH &&
368
+ (depth > 0 || entry.name === "archive"))
369
+ await visit(path.join(directory, entry.name), depth + 1);
370
+ }
371
+ };
372
+ for (const { dir } of listProjectDirs())
373
+ await visit(oceanPath(dir, "tasks"), 0);
374
+ return highest;
375
+ }
376
+ async function nextTaskNumber(projectId) {
377
+ const folder = projectFolder(projectId);
378
+ const manifests = await listProjectManifests();
379
+ const manifest = manifests.find((candidate) => candidate.id === folder);
380
+ const prefix = manifest?.prefix ?? LEGACY_PREFIX;
381
+ // T is one namespace shared by every project without a prefix: each honours the highest
382
+ // counter among them, so numbers a renumber moved out of the scan are never reissued.
383
+ const floor = prefix === LEGACY_PREFIX
384
+ ? Math.max(1, ...manifests
385
+ .filter((candidate) => !candidate.prefix)
386
+ .map((candidate) => candidate.nextTask ?? 1))
387
+ : (manifest?.nextTask ?? 1);
388
+ const number = Math.max(floor, (await highestTaskNumber(prefix)) + 1);
389
+ return { prefix, number, manifest };
390
+ }
391
+ // The id the next task of this project would get. A prediction only: another writer may take
392
+ // it before the caller writes, so a write goes through withNextTaskId.
393
+ export async function peekNextTaskId(projectId) {
394
+ return withProjectsLock(async () => {
395
+ const next = await nextTaskNumber(projectId);
396
+ return formatTaskId(next.prefix, next.number);
397
+ });
398
+ }
399
+ // Issues the next id and runs the write under the projects lock, so two creators never get one
400
+ // id. The counter advances only when the write succeeded; a refused write consumes nothing.
401
+ export async function withNextTaskId(projectId, run) {
402
+ return withProjectsLock(async () => {
403
+ const next = await nextTaskNumber(projectId);
404
+ const result = await run(formatTaskId(next.prefix, next.number));
405
+ if (result.ok && next.manifest)
406
+ await writeManifest({ ...next.manifest, nextTask: next.number + 1 });
407
+ return result;
408
+ });
409
+ }
410
+ // Resolves the active Ocean project from a working directory: no terminal-in-Ocean
411
+ // requirement. Match order is git root, then the exact cwd, then (as a last resort) whether
412
+ // the path is inside the Ocean workspace root itself, which is always the workspace project.
413
+ export async function resolveProject(cwd) {
414
+ const resolvedCwd = canonicalPath(cwd);
415
+ const gitRoot = findGitRoot(resolvedCwd);
416
+ const bindings = await listProjectBindings();
417
+ const containing = bindings
418
+ .filter((binding) => {
419
+ const bound = path.resolve(binding.path);
420
+ return (resolvedCwd === bound || resolvedCwd.startsWith(`${bound}${path.sep}`));
421
+ })
422
+ .sort((a, b) => path.resolve(b.path).length - path.resolve(a.path).length);
423
+ if (containing.length &&
424
+ (!containing[1] ||
425
+ path.resolve(containing[0].path).length >
426
+ path.resolve(containing[1].path).length)) {
427
+ const binding = containing[0];
428
+ return {
429
+ status: "bound",
430
+ projectId: binding.id,
431
+ name: binding.name,
432
+ path: binding.path,
433
+ matchedOn: path.resolve(binding.path) === resolvedCwd ? "cwd" : "path",
434
+ confidence: "high",
435
+ };
436
+ }
437
+ if (containing.length > 1)
438
+ return {
439
+ status: "ambiguous",
440
+ cwd: resolvedCwd,
441
+ candidates: containing,
442
+ confidence: "low",
443
+ };
444
+ const gitRootMatch = gitRoot
445
+ ? bindings.filter((binding) => path.resolve(binding.path) === gitRoot)
446
+ : [];
447
+ if (gitRootMatch.length === 1) {
448
+ return {
449
+ status: "bound",
450
+ projectId: gitRootMatch[0].id,
451
+ name: gitRootMatch[0].name,
452
+ path: gitRootMatch[0].path,
453
+ matchedOn: "git-root",
454
+ confidence: "high",
455
+ };
456
+ }
457
+ if (gitRootMatch.length > 1) {
458
+ return {
459
+ status: "ambiguous",
460
+ cwd: resolvedCwd,
461
+ candidates: gitRootMatch,
462
+ confidence: "low",
463
+ };
464
+ }
465
+ // A worktree beside its repo (house convention: <repo>-worktrees/<slug>) is that repo's project.
466
+ const main = gitRoot ? linkedWorktreeMain(gitRoot) : null;
467
+ if (main && main !== gitRoot) {
468
+ const viaMain = await resolveProject(main);
469
+ if (viaMain.status === "bound")
470
+ return viaMain;
471
+ }
472
+ const root = canonicalPath(oceanRoot());
473
+ const isFilesystemRoot = root === path.parse(root).root;
474
+ if (resolvedCwd === root ||
475
+ isFilesystemRoot ||
476
+ resolvedCwd.startsWith(`${root}${path.sep}`)) {
477
+ return {
478
+ status: "bound",
479
+ projectId: WORKSPACE_PROJECT_ID,
480
+ name: "Ocean",
481
+ path: root,
482
+ matchedOn: "ocean-root",
483
+ confidence: "high",
484
+ };
485
+ }
486
+ return { status: "unbound", cwd: resolvedCwd, gitRoot, confidence: "none" };
487
+ }
488
+ // Explicit registration/binding: never guesses on a name or path collision, always returns
489
+ // the conflict instead of silently overwriting an existing binding. `addPath` is the one
490
+ // deliberate exception: it adds another repo to an existing project (dashboard + backend).
491
+ export async function bindProject(name, targetPath, options = {}) {
492
+ if (!isValidProjectId(name))
493
+ throw new Error(`Invalid project id '${name}': use lowercase letters, digits, dot, underscore or hyphen`);
494
+ const resolvedPath = canonicalPath(targetPath);
495
+ return withProjectsLock(async () => {
496
+ const manifests = await listProjectManifests();
497
+ const legacy = await listLegacyBindings();
498
+ const bindings = combineBindings(manifests, legacy);
499
+ const existingAtPath = bindings.find((binding) => path.resolve(binding.path) === resolvedPath);
500
+ if (existingAtPath && existingAtPath.id !== name)
501
+ return {
502
+ binding: existingAtPath,
503
+ created: false,
504
+ conflict: existingAtPath,
505
+ };
506
+ if (existingAtPath?.manifest)
507
+ return { binding: existingAtPath, created: false };
508
+ // From here the path is free, or is this project's own legacy binding being folded in,
509
+ // which is never a conflict.
510
+ const existingByName = bindings.find((binding) => binding.id === name && binding.path !== resolvedPath);
511
+ if (existingByName && !existingAtPath && !options.addPath)
512
+ return {
513
+ binding: existingByName,
514
+ created: false,
515
+ conflict: existingByName,
516
+ };
517
+ const existing = manifests.find((manifest) => manifest.id === name);
518
+ // Auto-registration never takes over a folder someone else made (checked under the lock,
519
+ // so a folder created a moment ago by another writer counts).
520
+ if (options.autoRegistered &&
521
+ !existing &&
522
+ existsSync(oceanPath(projectDir(name))))
523
+ throw new ProjectFolderTaken(`${projectDir(name)} already exists; bind it explicitly`);
524
+ const manifest = existing
525
+ ? { ...existing, paths: [...existing.paths, portablePath(resolvedPath)] }
526
+ : await newManifest(name, {
527
+ name,
528
+ paths: [portablePath(resolvedPath)],
529
+ created: new Date().toISOString(),
530
+ autoRegistered: options.autoRegistered === true,
531
+ }, manifests);
532
+ await writeManifest(manifest);
533
+ const updated = manifests
534
+ .filter((candidate) => candidate.id !== name)
535
+ .concat(manifest);
536
+ const next = combineBindings(updated, legacy);
537
+ await saveProjectBindings(cacheBindings(updated, legacy));
538
+ const binding = next.find((candidate) => candidate.path === resolvedPath);
539
+ return { binding, created: true };
540
+ });
541
+ }
542
+ // One-time fold of the legacy bindings file into manifests. Generic on purpose (the kernel is
543
+ // public): a binding becomes a path of `04-projects/<id>/project.json` only when that project
544
+ // folder exists; any other binding is reported and left alone, never turned into a new folder.
545
+ export async function migrateProjectBindings(write) {
546
+ return withProjectsLock(async () => {
547
+ const legacy = await listLegacyBindings();
548
+ const manifests = await listProjectManifests();
549
+ const claimed = new Set(manifestBindings(manifests).map((binding) => binding.path));
550
+ const result = {
551
+ migrated: [],
552
+ orphaned: [],
553
+ written: write,
554
+ };
555
+ const updates = new Map();
556
+ for (const binding of legacy) {
557
+ const resolved = canonicalPath(binding.path);
558
+ if (claimed.has(resolved))
559
+ continue;
560
+ if (!isValidProjectId(binding.id)) {
561
+ result.orphaned.push({
562
+ id: binding.id,
563
+ path: resolved,
564
+ reason: "id is not a valid project folder name; bind it explicitly",
565
+ });
566
+ continue;
567
+ }
568
+ const folder = oceanPath(projectDir(binding.id));
569
+ if (!existsSync(folder)) {
570
+ result.orphaned.push({
571
+ id: binding.id,
572
+ path: resolved,
573
+ reason: `no project folder ${path.relative(oceanRoot(), folder)}; bind it explicitly`,
574
+ });
575
+ continue;
576
+ }
577
+ const base = updates.get(binding.id) ??
578
+ manifests.find((manifest) => manifest.id === binding.id) ??
579
+ (await newManifest(binding.id, {
580
+ name: binding.name || binding.id,
581
+ paths: [],
582
+ created: binding.boundAt || new Date().toISOString(),
583
+ autoRegistered: false,
584
+ }, [...manifests, ...updates.values()]));
585
+ updates.set(binding.id, {
586
+ ...base,
587
+ paths: [...base.paths, portablePath(resolved)],
588
+ });
589
+ claimed.add(resolved);
590
+ result.migrated.push({ id: binding.id, path: resolved });
591
+ }
592
+ if (write && updates.size) {
593
+ for (const manifest of updates.values())
594
+ await writeManifest(manifest);
595
+ await saveProjectBindings(cacheBindings(await listProjectManifests(), legacy));
596
+ }
597
+ return result;
598
+ });
599
+ }
600
+ const INDEX_BEGIN = "<!-- ocean:projects:begin (generated by `ocean project index --write`) -->";
601
+ const INDEX_END = "<!-- ocean:projects:end -->";
602
+ // Generated view of every project.json, kept in a managed block of 04-projects/registry.md so
603
+ // the hand-kept rows around it are never rewritten.
604
+ export async function renderProjectIndex() {
605
+ // Table cells: a `|` or a line break in a name or path would split the row.
606
+ const cell = (value) => value.replace(/[\r\n]+/g, " ").replaceAll("|", "\\|");
607
+ // One table per category, in PROJECT_CATEGORIES order; a project without one comes after,
608
+ // and a bad one shows as invalid instead of failing the whole index.
609
+ const category = (manifest) => {
610
+ try {
611
+ return projectCategory(manifest) ?? "uncategorized";
612
+ }
613
+ catch {
614
+ return "invalid";
615
+ }
616
+ };
617
+ const groups = new Map([...PROJECT_CATEGORIES, "uncategorized", "invalid"].map((name) => [
618
+ name,
619
+ [],
620
+ ]));
621
+ for (const manifest of await listProjectManifests())
622
+ groups
623
+ .get(category(manifest))
624
+ ?.push(`| \`${manifest.id}\` | ${cell(manifest.name)} | ${manifest.paths.length
625
+ ? manifest.paths.map((entry) => `\`${cell(entry)}\``).join("<br>")
626
+ : "—"} | ${manifest.autoRegistered ? "auto" : "manual"} |`);
627
+ const sections = [...groups]
628
+ .filter(([, rows]) => rows.length)
629
+ .flatMap(([name, rows]) => [
630
+ "",
631
+ `### ${name}`,
632
+ "",
633
+ "| Project | Name | Paths | Registered |",
634
+ "|---|---|---|---|",
635
+ ...rows,
636
+ ]);
637
+ return [
638
+ INDEX_BEGIN,
639
+ "## Projects (from project.json)",
640
+ ...sections,
641
+ INDEX_END,
642
+ ].join("\n");
643
+ }
644
+ export async function writeProjectIndex() {
645
+ return withProjectsLock(writeProjectIndexLocked);
646
+ }
647
+ async function writeProjectIndexLocked() {
648
+ const file = path.join(projectsRoot(), "registry.md");
649
+ let existing = "";
650
+ try {
651
+ existing = await readFile(file, "utf8");
652
+ }
653
+ catch (error) {
654
+ if (error.code !== "ENOENT")
655
+ throw error;
656
+ }
657
+ const block = await renderProjectIndex();
658
+ const begin = existing.indexOf(INDEX_BEGIN);
659
+ const end = existing.indexOf(INDEX_END);
660
+ let next;
661
+ if (begin >= 0 && end > begin)
662
+ next = `${existing.slice(0, begin)}${block}${existing.slice(end + INDEX_END.length)}`;
663
+ else if (begin >= 0 || end >= 0)
664
+ throw new Error(`${file}: unmatched ocean:projects marker; fix it by hand before regenerating`);
665
+ else
666
+ next = existing
667
+ ? `${existing.replace(/\n*$/, "")}\n\n${block}\n`
668
+ : `# Project registry\n\n${block}\n`;
669
+ if (next === existing)
670
+ return { file, changed: false };
671
+ await mkdir(path.dirname(file), { recursive: true });
672
+ await atomicWrite(file, next);
673
+ return { file, changed: true };
674
+ }
675
+ // Where a session may register its repo as a project without being asked (T-256 phase 2).
676
+ // OCEAN_CODE_ROOTS (a PATH-style list) overrides the default, ~/Documents.
677
+ export function codeRoots() {
678
+ const configured = oceanEnv("CODE_ROOTS");
679
+ return (configured ? configured.split(path.delimiter) : ["~/Documents"])
680
+ .map((entry) => entry.trim())
681
+ .filter(Boolean)
682
+ .map((entry) => path.resolve(expandHome(entry)));
683
+ }
684
+ // The existing project a client names (T-307): its id, or its name in any case (`LetSpeak` is
685
+ // `letspeak`), or that name's slug. Never a guess beyond that.
686
+ export function matchProject(manifests, ref) {
687
+ const name = ref.trim().toLowerCase();
688
+ const slug = projectSlug(ref);
689
+ return manifests.find((manifest) => manifest.id === ref ||
690
+ manifest.name.trim().toLowerCase() === name ||
691
+ (slug !== "" && manifest.id === slug))?.id;
692
+ }
693
+ // The names of the owner's Claude projects this machine has seen, as the always-on service last
694
+ // wrote them (T-307): reading Claude Desktop's caches takes too long for a tool call.
695
+ export function claudeProjectsFile() {
696
+ return oceanPath(SYSTEM_DIR, "state", "clients", "claude-projects.json");
697
+ }
698
+ export async function readClaudeProjects() {
699
+ try {
700
+ const parsed = JSON.parse(await readFile(claudeProjectsFile(), "utf8"));
701
+ return Array.isArray(parsed.names)
702
+ ? parsed.names.filter((name) => typeof name === "string")
703
+ : [];
704
+ }
705
+ catch {
706
+ return [];
707
+ }
708
+ }
709
+ export async function findProject(ref) {
710
+ return matchProject(await listProjectManifests(), ref);
711
+ }
712
+ // A Claude project (claude.ai or Claude Desktop) first seen in a conversation (T-307): it has no
713
+ // repo or path, so it is registered by name — 04-projects/<slug>/ with project.json, marked
714
+ // auto-registered, and the standard layout. An existing project with that id or name is
715
+ // returned instead; a folder that exists without a manifest is never taken over.
716
+ export async function registerNamedProject(name, tags = []) {
717
+ return withProjectsLock(async () => {
718
+ const manifests = await listProjectManifests();
719
+ const known = matchProject(manifests, name);
720
+ if (known)
721
+ return { id: known, created: false };
722
+ const id = projectSlug(name);
723
+ if (!isValidProjectId(id))
724
+ throw new Error(`no usable project id from '${name}'`);
725
+ if (existsSync(oceanPath(projectDir(id))))
726
+ throw new ProjectFolderTaken(`${projectDir(id)}/ exists without project.json; register it explicitly`);
727
+ const manifest = await newManifest(id, {
728
+ name: name.trim(),
729
+ paths: [],
730
+ created: new Date().toISOString(),
731
+ autoRegistered: true,
732
+ }, manifests);
733
+ await writeManifest(tags.length ? { ...manifest, tags } : manifest);
734
+ await ensureProjectLayout(id);
735
+ await writeProjectIndexLocked();
736
+ return { id, created: true };
737
+ });
738
+ }
739
+ export function projectSlug(name) {
740
+ return name
741
+ .toLowerCase()
742
+ .replace(/[^a-z0-9._-]+/g, "-")
743
+ .replace(/^[^a-z0-9]+/, "")
744
+ .slice(0, 64)
745
+ .replace(/[-._]+$/, "");
746
+ }
747
+ async function realOrSelf(target) {
748
+ try {
749
+ return await realpath(target);
750
+ }
751
+ catch {
752
+ return path.resolve(target);
753
+ }
754
+ }
755
+ function within(root, target) {
756
+ const prefix = root.endsWith(path.sep) ? root : `${root}${path.sep}`;
757
+ return target === root || target.startsWith(prefix);
758
+ }
759
+ class ProjectFolderTaken extends Error {
760
+ }
761
+ // A session opened in a git repo that no project claims registers that repo as its own
762
+ // project: 04-projects/<slug>/ with project.json and the standard layout (ensureProjectLayout:
763
+ // tasks/, memory/, docs/, MEMORY.md). Automatic runs stay
764
+ // under the code roots, out of the temp dir and off a code root itself; `explicit` (the
765
+ // `ocean project register` command) is the owner asking, so only the hard rules apply: a git
766
+ // repo, not a linked worktree or submodule, not the Ocean workspace, never over an existing
767
+ // project folder. Sibling repos are never merged: a code-root folder often holds unrelated
768
+ // repos, so joining one project to another stays explicit (`bind --add`).
769
+ export async function autoRegisterProject(cwd, options = {}) {
770
+ let resolution = options.resolution ?? (await resolveProject(cwd));
771
+ const skipped = (reason) => ({
772
+ status: "skipped",
773
+ reason,
774
+ resolution,
775
+ });
776
+ if (!options.explicit && oceanEnv("AUTO_REGISTER") === "0")
777
+ return skipped("disabled by OCEAN_AUTO_REGISTER=0");
778
+ if (resolution.status !== "unbound")
779
+ return skipped(`already ${resolution.status}`);
780
+ if (!resolution.gitRoot)
781
+ return skipped("not inside a git repository");
782
+ if (!statSync(path.join(resolution.gitRoot, ".git")).isDirectory())
783
+ return skipped("a linked worktree or submodule; bind it explicitly");
784
+ const repo = await realOrSelf(resolution.gitRoot);
785
+ if (within(await realOrSelf(oceanRoot()), repo))
786
+ return skipped("inside the Ocean workspace");
787
+ if (!options.explicit) {
788
+ const roots = await Promise.all(codeRoots().map(realOrSelf));
789
+ if (roots.includes(repo))
790
+ return skipped("is a code root itself");
791
+ const root = roots.find((candidate) => within(candidate, repo));
792
+ if (!root)
793
+ return skipped("outside the code roots");
794
+ // The temp dir is excluded unless the owner deliberately put a code root inside it.
795
+ const temp = await realOrSelf(os.tmpdir());
796
+ if (within(temp, repo) && !within(temp, root))
797
+ return skipped("inside the temp directory");
798
+ }
799
+ const id = projectSlug(path.basename(repo));
800
+ if (!isValidProjectId(id))
801
+ return skipped(`no usable id from '${repo}'`);
802
+ let result;
803
+ try {
804
+ result = await bindProject(id, resolution.gitRoot, {
805
+ autoRegistered: true,
806
+ });
807
+ }
808
+ catch (error) {
809
+ if (!(error instanceof ProjectFolderTaken))
810
+ throw error;
811
+ // Another session may have registered this very repo a moment ago.
812
+ resolution = await resolveProject(cwd);
813
+ return skipped(error.message);
814
+ }
815
+ if (result.conflict) {
816
+ resolution = await resolveProject(cwd);
817
+ return skipped(`conflicts with project '${result.conflict.id}'`);
818
+ }
819
+ await ensureProjectLayout(id);
820
+ resolution = {
821
+ status: "bound",
822
+ projectId: id,
823
+ name: id,
824
+ path: result.binding.path,
825
+ matchedOn: result.binding.path === path.resolve(cwd) ? "cwd" : "path",
826
+ confidence: "high",
827
+ };
828
+ return {
829
+ status: "registered",
830
+ projectId: id,
831
+ path: result.binding.path,
832
+ resolution,
833
+ };
834
+ }
835
+ function memoryHeading(id) {
836
+ return `# ${id} — memory`;
837
+ }
838
+ // The only entries a project folder holds at its root. Lessons and decisions go in memory/
839
+ // (indexed by MEMORY.md, which every session in the project loads); reference material goes
840
+ // in docs/ (indexed by docs/README.md); work goes in tasks/. index.md is the generated board.
841
+ export const PROJECT_ROOT_ENTRIES = [
842
+ "project.json",
843
+ "MEMORY.md",
844
+ "README.md",
845
+ "index.md",
846
+ "tasks",
847
+ "memory",
848
+ "docs",
849
+ ];
850
+ function memorySeed(id) {
851
+ return `${memoryHeading(id)}
852
+
853
+ Index of this project's memory. Loaded only for sessions in this project.
854
+
855
+ Layout — nothing else goes at the project root:
856
+ - Lessons, decisions, gotchas → one file in \`memory/\`, plus one line here.
857
+ - Reference material (requirements, API notes, plans, maps) → \`docs/\`, plus one line in \`docs/README.md\`.
858
+ - Work → \`tasks/<ID>/task.md\`.
859
+ `;
860
+ }
861
+ function docsHeading(id) {
862
+ return `# ${id} — docs`;
863
+ }
864
+ function docsSeed(id) {
865
+ return `${docsHeading(id)}
866
+
867
+ Reference material for this project: requirements, API notes, plans, maps. One line per file
868
+ below. Status lives in the tasks, never here.
869
+ `;
870
+ }
871
+ // Creates whatever of the standard layout is missing and never overwrites a file. Returns the
872
+ // paths it created, relative to the project folder.
873
+ export async function ensureProjectLayout(projectId) {
874
+ const folder = oceanPath(projectDir(projectId));
875
+ const created = [];
876
+ for (const directory of ["tasks", "memory", "docs"])
877
+ if (await mkdir(path.join(folder, directory), { recursive: true }))
878
+ created.push(`${directory}/`);
879
+ const seeds = [
880
+ ["MEMORY.md", memorySeed(projectId)],
881
+ [path.join("docs", "README.md"), docsSeed(projectId)],
882
+ ];
883
+ for (const [file, text] of seeds) {
884
+ try {
885
+ await writeFile(path.join(folder, file), text, { flag: "wx" });
886
+ created.push(file.split(path.sep).join("/"));
887
+ }
888
+ catch (error) {
889
+ if (error.code !== "EEXIST")
890
+ throw error;
891
+ }
892
+ }
893
+ return created;
894
+ }
895
+ // Undoes a binding (a wrong auto-registration, a moved repo). Only the path goes; the project
896
+ // folder and its tasks stay, since deleting them is the owner's call.
897
+ export async function unbindProjectPath(targetPath) {
898
+ const resolvedPath = canonicalPath(targetPath);
899
+ return withProjectsLock(async () => {
900
+ const manifests = await listProjectManifests();
901
+ const legacy = await listLegacyBindings();
902
+ // The binding whose path contains the given one, longest first, so `unbind .` works from
903
+ // anywhere inside the repo.
904
+ const removed = combineBindings(manifests, legacy)
905
+ .filter((binding) => within(path.resolve(binding.path), resolvedPath))
906
+ .sort((a, b) => b.path.length - a.path.length)[0] ?? null;
907
+ if (!removed)
908
+ return { removed };
909
+ const boundPath = path.resolve(removed.path);
910
+ const owner = manifests.find((manifest) => manifest.paths.some((stored) => canonicalPath(stored) === boundPath));
911
+ if (owner)
912
+ await writeManifest({
913
+ ...owner,
914
+ paths: owner.paths.filter((stored) => canonicalPath(stored) !== boundPath),
915
+ });
916
+ // A legacy entry for the same path would otherwise take the binding back.
917
+ const keptLegacy = legacy.filter((binding) => canonicalPath(binding.path) !== boundPath);
918
+ await saveProjectBindings(cacheBindings(await listProjectManifests(), keptLegacy));
919
+ return { removed };
920
+ });
921
+ }
922
+ // Workspace text that may name a project folder by path: the records, the projects, the charter.
923
+ const REFERENCE_FILE = /\.(md|json|ya?ml|toml)$/i;
924
+ const REFERENCE_MAX_BYTES = 2_000_000;
925
+ // Rewrites `from` (a project folder relative to the Ocean root) to `to` in workspace text, as a
926
+ // whole path: `04-projects/ocean` never touches `04-projects/ocean-ielts` or `ocean.md`, but
927
+ // does match inside an absolute path and before a full stop. Dot folders are skipped. Returns
928
+ // the files that change, relative to the Ocean root; writes them only when `write`.
929
+ export async function rewriteProjectReferences(from, to, write) {
930
+ const escaped = from.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
931
+ const pattern = new RegExp(`(?<![A-Za-z0-9_.-])${escaped}(?![A-Za-z0-9_-]|\\.[A-Za-z0-9])`, "g");
932
+ const changed = [];
933
+ const visit = async (dir) => {
934
+ let entries;
935
+ try {
936
+ entries = await readdir(dir, { withFileTypes: true });
937
+ }
938
+ catch {
939
+ return;
940
+ }
941
+ for (const entry of entries) {
942
+ if (entry.name.startsWith(".") || entry.name === "node_modules")
943
+ continue;
944
+ const file = path.join(dir, entry.name);
945
+ if (entry.isDirectory())
946
+ await visit(file);
947
+ else if (entry.isFile() &&
948
+ REFERENCE_FILE.test(entry.name) &&
949
+ statSync(file).size <= REFERENCE_MAX_BYTES) {
950
+ const text = await readFile(file, "utf8");
951
+ const next = text.replace(pattern, to);
952
+ if (next === text)
953
+ continue;
954
+ changed.push(path.relative(oceanRoot(), file).split(path.sep).join("/"));
955
+ if (write)
956
+ await atomicWrite(file, next);
957
+ }
958
+ }
959
+ };
960
+ for (const root of new Set([...BRAIN_RECORD_DIRS, PROJECTS_DIR, CHARTER_DIR]))
961
+ await visit(oceanPath(root));
962
+ return changed.sort();
963
+ }
964
+ // Files a project under a category folder (T-311) and rewrites the workspace paths that name
965
+ // its old folder. A dry run unless `write`: it reports the move and the files that would change.
966
+ export async function moveProject(id, category, write = false) {
967
+ if (!isValidProjectId(id))
968
+ throw new Error("Project ids must be lowercase letters, digits, . _ or -");
969
+ if (!isProjectCategory(category))
970
+ throw new Error(`category must be one of ${PROJECT_CATEGORIES.join(", ")}`);
971
+ return withProjectsLock(async () => {
972
+ const manifest = (await listProjectManifests()).find((candidate) => candidate.id === projectFolder(id));
973
+ if (!manifest)
974
+ throw new Error(`No project.json for '${id}'`);
975
+ const from = projectDir(manifest.id);
976
+ const to = path.posix.join(PROJECTS_DIR, category, manifest.id);
977
+ if (from === to)
978
+ return { id: manifest.id, from, to, moved: false, references: [] };
979
+ if (existsSync(oceanPath(to)))
980
+ throw new Error(`${to} already exists`);
981
+ if (!write)
982
+ return {
983
+ id: manifest.id,
984
+ from,
985
+ to,
986
+ moved: false,
987
+ references: await rewriteProjectReferences(from, to, false),
988
+ };
989
+ await mkdir(path.dirname(oceanPath(to)), { recursive: true });
990
+ await rename(oceanPath(from), oceanPath(to));
991
+ // A category folder left empty goes; a flat project's written category is dropped.
992
+ if (folderCategory(from))
993
+ await rmdir(path.dirname(oceanPath(from))).catch(() => { });
994
+ await writeManifest(manifest);
995
+ const references = await rewriteProjectReferences(from, to, true);
996
+ if ((await readFile(path.join(projectsRoot(), "registry.md"), "utf8").catch(() => "")).includes(INDEX_BEGIN))
997
+ await writeProjectIndexLocked();
998
+ return { id: manifest.id, from, to, moved: true, references };
999
+ });
1000
+ }
1001
+ // Renames a project folder (fixes a wrong auto-registered id). Refused while the project has
1002
+ // tasks: their records carry the project id, and rewriting them belongs to phase 3.
1003
+ export async function renameProject(from, to) {
1004
+ if (!isValidProjectId(from) || !isValidProjectId(to))
1005
+ throw new Error("Project ids must be lowercase letters, digits, . _ or -");
1006
+ return withProjectsLock(async () => {
1007
+ // The project keeps its category folder.
1008
+ const source = oceanPath(projectDir(from));
1009
+ const target = path.join(path.dirname(source), to);
1010
+ const manifest = (await listProjectManifests()).find((candidate) => candidate.id === from);
1011
+ if (!manifest)
1012
+ throw new Error(`No project.json for '${from}'`);
1013
+ if (existsSync(target) || listProjectDirs().some(({ id }) => id === to))
1014
+ throw new Error(`project '${to}' already exists`);
1015
+ const tasks = await readdir(path.join(source, "tasks")).catch(() => []);
1016
+ if (tasks.some((entry) => !entry.startsWith(".")))
1017
+ throw new Error(`'${from}' has tasks; renaming a project with tasks is not supported yet`);
1018
+ await rename(source, target);
1019
+ await writeManifest({
1020
+ ...manifest,
1021
+ id: to,
1022
+ name: manifest.name === from ? to : manifest.name,
1023
+ });
1024
+ for (const [file, heading] of [
1025
+ ["MEMORY.md", memoryHeading],
1026
+ [path.join("docs", "README.md"), docsHeading],
1027
+ ]) {
1028
+ const index = path.join(target, file);
1029
+ try {
1030
+ const text = await readFile(index, "utf8");
1031
+ if (text.startsWith(heading(from)))
1032
+ await atomicWrite(index, `${heading(to)}${text.slice(heading(from).length)}`);
1033
+ }
1034
+ catch (error) {
1035
+ if (error.code !== "ENOENT")
1036
+ throw error;
1037
+ }
1038
+ }
1039
+ if ((await readFile(path.join(projectsRoot(), "registry.md"), "utf8").catch(() => "")).includes(INDEX_BEGIN))
1040
+ await writeProjectIndexLocked();
1041
+ await saveProjectBindings(cacheBindings(await listProjectManifests(), await listLegacyBindings()));
1042
+ return { from, to };
1043
+ });
1044
+ }
1045
+ // Project folders under 04-projects that hold a tasks/ directory, sorted.
1046
+ export async function projectsWithTasks() {
1047
+ const projects = [];
1048
+ for (const { id, dir } of listProjectDirs()) {
1049
+ try {
1050
+ await readdir(oceanPath(dir, "tasks"));
1051
+ projects.push(id);
1052
+ }
1053
+ catch {
1054
+ /* no tasks/ here */
1055
+ }
1056
+ }
1057
+ return [...new Set(projects)].sort();
1058
+ }
1059
+ // The project a folder belongs to, or null when it belongs to none. A registry that cannot be
1060
+ // read throws: no guessing which project a folder is.
1061
+ export async function folderProject(cwd) {
1062
+ const resolution = await resolveProject(cwd);
1063
+ return resolution.status === "bound" ? resolution.projectId : null;
1064
+ }
1065
+ //# sourceMappingURL=project-resolution.js.map