@xiaobuyu/nodesign 0.0.1

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 (372) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +140 -0
  3. package/bin/nodesign.js +176 -0
  4. package/package.json +104 -0
  5. package/server/api/README.md +45 -0
  6. package/server/api/_guard.js +75 -0
  7. package/server/api/admin.js +187 -0
  8. package/server/api/assets/docx-page.js +103 -0
  9. package/server/api/assets/helpers.js +22 -0
  10. package/server/api/assets/notes.js +144 -0
  11. package/server/api/assets.js +896 -0
  12. package/server/api/board.js +57 -0
  13. package/server/api/browse.js +138 -0
  14. package/server/api/canvas.js +444 -0
  15. package/server/api/chatai.js +324 -0
  16. package/server/api/exports/build-standalone.js +980 -0
  17. package/server/api/exports/cards.js +118 -0
  18. package/server/api/exports/docx-pdf.js +46 -0
  19. package/server/api/exports/export-page.js +142 -0
  20. package/server/api/exports/handoff.js +147 -0
  21. package/server/api/exports.js +690 -0
  22. package/server/api/instruction.js +75 -0
  23. package/server/api/local.js +115 -0
  24. package/server/api/me.js +116 -0
  25. package/server/api/memory.js +185 -0
  26. package/server/api/pending-changes.js +366 -0
  27. package/server/api/plugins.js +164 -0
  28. package/server/api/projects.js +220 -0
  29. package/server/api/publish.js +64 -0
  30. package/server/api/recent.js +73 -0
  31. package/server/api/sessions.js +589 -0
  32. package/server/api/skills.js +77 -0
  33. package/server/api/standalone-fit.js +168 -0
  34. package/server/api/turn-compose.js +180 -0
  35. package/server/api/turn-inflight.js +44 -0
  36. package/server/api/turn.js +669 -0
  37. package/server/auth/README.md +17 -0
  38. package/server/auth/middleware.js +138 -0
  39. package/server/auth/origin-guard.js +71 -0
  40. package/server/auth/session.js +108 -0
  41. package/server/auth/tier.js +113 -0
  42. package/server/auth/users-store.js +348 -0
  43. package/server/edit/README.md +29 -0
  44. package/server/engine/README.md +113 -0
  45. package/server/engine/agent/agent-shared.js +550 -0
  46. package/server/engine/agent/auto-mode-default-hard-deny.txt +4 -0
  47. package/server/engine/agent/auto-mode-rules.js +78 -0
  48. package/server/engine/agent/context.js +418 -0
  49. package/server/engine/agent/events.js +464 -0
  50. package/server/engine/agent/hooks/canvas-validate.js +199 -0
  51. package/server/engine/agent/hooks/failure.js +120 -0
  52. package/server/engine/agent/hooks/file-events.js +75 -0
  53. package/server/engine/agent/hooks/lifecycle.js +233 -0
  54. package/server/engine/agent/hooks/post-canvas-focus.js +80 -0
  55. package/server/engine/agent/hooks/post-guidance.js +206 -0
  56. package/server/engine/agent/hooks/post-subagent-report.js +104 -0
  57. package/server/engine/agent/hooks/post-trim.js +58 -0
  58. package/server/engine/agent/hooks/pre-board-neighborhood.js +34 -0
  59. package/server/engine/agent/hooks/pre-defaults.js +84 -0
  60. package/server/engine/agent/hooks/pre-injectors.js +277 -0
  61. package/server/engine/agent/hooks/pre-performance-log-guard.js +78 -0
  62. package/server/engine/agent/hooks/pre-starter-files.js +61 -0
  63. package/server/engine/agent/hooks/pre-workspace-scope-guard.js +78 -0
  64. package/server/engine/agent/hooks/site-validate.js +94 -0
  65. package/server/engine/agent/hooks/tool-prompts.js +31 -0
  66. package/server/engine/agent/hooks/turn-state-memory.js +56 -0
  67. package/server/engine/agent/hooks/user-prompt-submit.js +215 -0
  68. package/server/engine/agent/hooks.js +354 -0
  69. package/server/engine/agent/init-contract.js +69 -0
  70. package/server/engine/agent/isolation.js +159 -0
  71. package/server/engine/agent/model-context.js +359 -0
  72. package/server/engine/agent/model-table.js +292 -0
  73. package/server/engine/agent/plugin-loader.js +266 -0
  74. package/server/engine/agent/prompts/nodesign-prelude.md +336 -0
  75. package/server/engine/agent/prompts/tools/ask-user-question-protocol.md +71 -0
  76. package/server/engine/agent/prompts/tools/direct-edit-protocol.md +98 -0
  77. package/server/engine/agent/prompts/tools/generate-image-cookbook.gemini-gateway.md +583 -0
  78. package/server/engine/agent/prompts/tools/generate-image-cookbook.md +483 -0
  79. package/server/engine/agent/prompts/tools/hybrid-reference.md +100 -0
  80. package/server/engine/agent/prompts/tools/paint-still-cookbook.md +270 -0
  81. package/server/engine/agent/prompts/tools/roll-film-cookbook.md +70 -0
  82. package/server/engine/agent/prompts/tools/site-reference.md +106 -0
  83. package/server/engine/agent/prompts/tools/tweaks-syntax.md +125 -0
  84. package/server/engine/agent/prompts/tools/vision-checker-dispatch.md +48 -0
  85. package/server/engine/agent/session-loop.js +1024 -0
  86. package/server/engine/agent/session-model.js +164 -0
  87. package/server/engine/agent/skill.js +204 -0
  88. package/server/engine/agent/system-prompts.js +86 -0
  89. package/server/engine/agent/task-events.js +78 -0
  90. package/server/engine/agents/ds-extractor.md +132 -0
  91. package/server/engine/agents/explorer.md +190 -0
  92. package/server/engine/agents/index.js +230 -0
  93. package/server/engine/agents/schemas/design-system.json +156 -0
  94. package/server/engine/agents/schemas/tweak-schema.json +113 -0
  95. package/server/engine/agents/tweak-proposer.md +127 -0
  96. package/server/engine/agents/vision-checker.md +254 -0
  97. package/server/engine/browse/capture.js +422 -0
  98. package/server/engine/browse/card.js +137 -0
  99. package/server/engine/browse/handover.js +59 -0
  100. package/server/engine/browse/page-digest.js +119 -0
  101. package/server/engine/browse/refs.js +196 -0
  102. package/server/engine/browse/registry.js +307 -0
  103. package/server/engine/browse/screencast.js +170 -0
  104. package/server/engine/browse/state.js +121 -0
  105. package/server/engine/chatai/chat-log.js +79 -0
  106. package/server/engine/chatai/index.js +193 -0
  107. package/server/engine/chatai/openai-compat.js +152 -0
  108. package/server/engine/chatai/orchestrate.js +309 -0
  109. package/server/engine/chatai/perform.js +40 -0
  110. package/server/engine/chatai/summarize.js +79 -0
  111. package/server/engine/mcp/capability-gate.js +52 -0
  112. package/server/engine/mcp/index.js +339 -0
  113. package/server/engine/mcp/param-sanitizer.js +142 -0
  114. package/server/engine/mcp/tools/arrange-on-board.js +124 -0
  115. package/server/engine/mcp/tools/artifact-session.js +278 -0
  116. package/server/engine/mcp/tools/browse-computer.js +421 -0
  117. package/server/engine/mcp/tools/browse-find-batch.js +183 -0
  118. package/server/engine/mcp/tools/browse-screenshot.js +157 -0
  119. package/server/engine/mcp/tools/browse.js +480 -0
  120. package/server/engine/mcp/tools/build-docx.js +101 -0
  121. package/server/engine/mcp/tools/clear-pending-changes.js +99 -0
  122. package/server/engine/mcp/tools/create-on-board.js +155 -0
  123. package/server/engine/mcp/tools/crystallize-skill.js +168 -0
  124. package/server/engine/mcp/tools/deliver-files.js +0 -0
  125. package/server/engine/mcp/tools/explain-style.js +237 -0
  126. package/server/engine/mcp/tools/export-handoff.js +133 -0
  127. package/server/engine/mcp/tools/expose-tweaks.js +149 -0
  128. package/server/engine/mcp/tools/generate-image.js +842 -0
  129. package/server/engine/mcp/tools/get-computed-styles.js +164 -0
  130. package/server/engine/mcp/tools/get-pending-changes.js +205 -0
  131. package/server/engine/mcp/tools/h3box-ssh.js +84 -0
  132. package/server/engine/mcp/tools/helpers/acquire-page.js +82 -0
  133. package/server/engine/mcp/tools/helpers/motion-lab.js +554 -0
  134. package/server/engine/mcp/tools/helpers/motion-scroll.js +112 -0
  135. package/server/engine/mcp/tools/helpers/perception-page.js +215 -0
  136. package/server/engine/mcp/tools/helpers/reference-download.js +103 -0
  137. package/server/engine/mcp/tools/helpers/rembg-bridge.py +80 -0
  138. package/server/engine/mcp/tools/helpers/rembg.js +273 -0
  139. package/server/engine/mcp/tools/helpers/shot-pipeline.js +280 -0
  140. package/server/engine/mcp/tools/highlight.js +68 -0
  141. package/server/engine/mcp/tools/list-pages.js +222 -0
  142. package/server/engine/mcp/tools/lookup-tags.js +59 -0
  143. package/server/engine/mcp/tools/navigate-to-page.js +60 -0
  144. package/server/engine/mcp/tools/organize-board.js +64 -0
  145. package/server/engine/mcp/tools/paint-still.js +434 -0
  146. package/server/engine/mcp/tools/pin-to-board.js +130 -0
  147. package/server/engine/mcp/tools/preview-deck.js +130 -0
  148. package/server/engine/mcp/tools/profile-scroll.js +305 -0
  149. package/server/engine/mcp/tools/publish-site.js +102 -0
  150. package/server/engine/mcp/tools/query-elements.js +195 -0
  151. package/server/engine/mcp/tools/read-board.js +103 -0
  152. package/server/engine/mcp/tools/read-document.js +93 -0
  153. package/server/engine/mcp/tools/read-page.js +264 -0
  154. package/server/engine/mcp/tools/read-tavern-json.js +143 -0
  155. package/server/engine/mcp/tools/record-decision.js +140 -0
  156. package/server/engine/mcp/tools/relate-on-board.js +110 -0
  157. package/server/engine/mcp/tools/remove-background.js +391 -0
  158. package/server/engine/mcp/tools/report-issue.js +115 -0
  159. package/server/engine/mcp/tools/roll-film.js +266 -0
  160. package/server/engine/mcp/tools/screenshot-docx.js +138 -0
  161. package/server/engine/mcp/tools/screenshot-url.js +195 -0
  162. package/server/engine/mcp/tools/screenshot.js +583 -0
  163. package/server/engine/mcp/tools/tier-gate.js +96 -0
  164. package/server/engine/mcp/tools/trace-motion.js +215 -0
  165. package/server/engine/mcp/tools/web-search.js +548 -0
  166. package/server/engine/motion/inventory.js +349 -0
  167. package/server/engine/perception/session.js +235 -0
  168. package/server/engine/plugins/nodesign/.claude-plugin/plugin.json +5 -0
  169. package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/SKILL.md +192 -0
  170. package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/canvas.template.html +667 -0
  171. package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/patterns/hybrid-grid.md +22 -0
  172. package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/patterns/image-led-cover.md +25 -0
  173. package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/patterns/portrait.md +23 -0
  174. package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/patterns/quote-backdrop.md +21 -0
  175. package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/patterns/section-divider.md +23 -0
  176. package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/patterns/text-led.md +21 -0
  177. package/server/engine/plugins/nodesign/skills/docx-craft/SKILL.md +162 -0
  178. package/server/engine/plugins/nodesign/skills/docx-craft/references/token-schema.md +407 -0
  179. package/server/engine/plugins/nodesign/skills/docx-craft//346/226/207/346/241/243.template.json +80 -0
  180. package/server/engine/plugins/nodesign/skills/rp-craft/SKILL.md +228 -0
  181. package/server/engine/plugins/nodesign/skills/rp-craft/patterns//346/274/224/345/207/272/351/241/265-/346/234/200/345/260/217/345/256/236/347/216/260.html +78 -0
  182. package/server/engine/plugins/nodesign/skills/rp-craft//346/274/224/345/207/272.template.js +268 -0
  183. package/server/engine/plugins/nodesign/skills/site-craft/SKILL.md +323 -0
  184. package/server/engine/plugins/nodesign/skills/site-craft/patterns/build-lane.md +134 -0
  185. package/server/engine/plugins/nodesign/skills/site-craft/patterns/cutout-collage.md +61 -0
  186. package/server/engine/plugins/nodesign/skills/site-craft/patterns/mock-app.md +163 -0
  187. package/server/engine/plugins/nodesign/skills/site-craft/patterns/page-transitions.md +86 -0
  188. package/server/engine/runs/active-runs.js +698 -0
  189. package/server/engine/runs/live-turn.js +252 -0
  190. package/server/engine/runs/store.js +364 -0
  191. package/server/engine/runs/turn-relay.js +217 -0
  192. package/server/engine/runtime/workspace.js +164 -0
  193. package/server/index.js +235 -0
  194. package/server/lib/artifact-file-path.js +80 -0
  195. package/server/lib/artifact-target.js +284 -0
  196. package/server/lib/asset-refs.js +239 -0
  197. package/server/lib/async-queue.js +104 -0
  198. package/server/lib/auto-relations.js +130 -0
  199. package/server/lib/binding-types.js +60 -0
  200. package/server/lib/board-kind-sizes.js +62 -0
  201. package/server/lib/board-relations.js +133 -0
  202. package/server/lib/browse-proxy.js +180 -0
  203. package/server/lib/canvas-id.js +36 -0
  204. package/server/lib/cover.js +227 -0
  205. package/server/lib/danbooru-tags.js +291 -0
  206. package/server/lib/doc-extract.js +156 -0
  207. package/server/lib/docx/build-from-source.js +260 -0
  208. package/server/lib/docx/build.js +490 -0
  209. package/server/lib/docx/dump-styles.js +287 -0
  210. package/server/lib/docx/fonts/nodesign-cjk.conf +164 -0
  211. package/server/lib/docx/merge-runs.js +129 -0
  212. package/server/lib/docx/numbering.js +218 -0
  213. package/server/lib/docx/order.js +152 -0
  214. package/server/lib/docx/rawzip.js +172 -0
  215. package/server/lib/docx/render.js +92 -0
  216. package/server/lib/docx/text-lint.js +225 -0
  217. package/server/lib/docx/tokens.js +335 -0
  218. package/server/lib/docx/units.js +29 -0
  219. package/server/lib/docx/xml.js +291 -0
  220. package/server/lib/docx-pages.js +187 -0
  221. package/server/lib/export-collect.js +289 -0
  222. package/server/lib/export-package.js +210 -0
  223. package/server/lib/html-srcset.js +145 -0
  224. package/server/lib/image-variant.js +460 -0
  225. package/server/lib/ingress/forward-openai-chat.js +305 -0
  226. package/server/lib/ingress/openai-chat.js +491 -0
  227. package/server/lib/ingress/session-notice.js +66 -0
  228. package/server/lib/ingress/session-routes.js +68 -0
  229. package/server/lib/ingress/slot-probe.js +130 -0
  230. package/server/lib/ingress/upstream-billing.js +52 -0
  231. package/server/lib/ingress/upstream-fail-streak.js +72 -0
  232. package/server/lib/ingress/upstream-truncation.js +48 -0
  233. package/server/lib/issues-store.js +182 -0
  234. package/server/lib/kinds/deck.js +72 -0
  235. package/server/lib/kinds/docx.js +283 -0
  236. package/server/lib/kinds/file-kinds.js +113 -0
  237. package/server/lib/kinds/index.js +202 -0
  238. package/server/lib/kinds/site.js +161 -0
  239. package/server/lib/model-ingress.js +543 -0
  240. package/server/lib/moderation.js +255 -0
  241. package/server/lib/notice-store.js +103 -0
  242. package/server/lib/plugin-install.js +104 -0
  243. package/server/lib/plugin-validator.js +721 -0
  244. package/server/lib/publish-store.js +115 -0
  245. package/server/lib/quick-summary.js +52 -0
  246. package/server/lib/quota.js +270 -0
  247. package/server/lib/rate-window.js +29 -0
  248. package/server/lib/reference-assets.js +92 -0
  249. package/server/lib/region-shot.js +135 -0
  250. package/server/lib/safe-path.js +73 -0
  251. package/server/lib/sdk-session.js +33 -0
  252. package/server/lib/showcase-store.js +92 -0
  253. package/server/lib/site-publish.js +465 -0
  254. package/server/lib/ssrf-guard.js +432 -0
  255. package/server/lib/task-scan.js +158 -0
  256. package/server/lib/tavern-json.js +154 -0
  257. package/server/lib/video-variant.js +237 -0
  258. package/server/lib/workspace-path.js +33 -0
  259. package/server/ops/fix-sdk-musl.mjs +40 -0
  260. package/server/ops/install-macos-fonts.sh +114 -0
  261. package/server/ops/macos-fonts.conf +336 -0
  262. package/server/ops/sandbox-shim/bwrap +57 -0
  263. package/server/projects/assets-summary.js +119 -0
  264. package/server/projects/auto-name.js +53 -0
  265. package/server/projects/board-store.js +640 -0
  266. package/server/projects/move-entry.js +103 -0
  267. package/server/projects/store.js +270 -0
  268. package/server/projects/ui-config.js +54 -0
  269. package/server/projects/workspace-templates.js +53 -0
  270. package/server/projects/workspace.js +1025 -0
  271. package/server/runtime/capabilities.js +153 -0
  272. package/server/runtime/local-config.js +177 -0
  273. package/server/runtime/local-env.js +88 -0
  274. package/server/runtime/platform.js +342 -0
  275. package/server/runtime/profile.js +72 -0
  276. package/server/services/rembg-launcher.js +313 -0
  277. package/server/services/rembg-service.py +334 -0
  278. package/server/shared/README.md +25 -0
  279. package/server/shared/deck.js +54 -0
  280. package/server/shared/time.js +49 -0
  281. package/server/style-pipeline/README.md +53 -0
  282. package/server/ws/broker.js +36 -0
  283. package/server/ws/browse-channel.js +177 -0
  284. package/server/ws/index.js +386 -0
  285. package/web/README.md +89 -0
  286. package/web/dist/assets/BrowserWindow-Dc7J7CYc.js +11 -0
  287. package/web/dist/assets/DeckWindow-4G0xX7P3.js +16 -0
  288. package/web/dist/assets/DocxWindow-BXQsoPkh.js +16 -0
  289. package/web/dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
  290. package/web/dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
  291. package/web/dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
  292. package/web/dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
  293. package/web/dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
  294. package/web/dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
  295. package/web/dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
  296. package/web/dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
  297. package/web/dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
  298. package/web/dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
  299. package/web/dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
  300. package/web/dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
  301. package/web/dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
  302. package/web/dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
  303. package/web/dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
  304. package/web/dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
  305. package/web/dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
  306. package/web/dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
  307. package/web/dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
  308. package/web/dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
  309. package/web/dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
  310. package/web/dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
  311. package/web/dist/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
  312. package/web/dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
  313. package/web/dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
  314. package/web/dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
  315. package/web/dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
  316. package/web/dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
  317. package/web/dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
  318. package/web/dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
  319. package/web/dist/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
  320. package/web/dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
  321. package/web/dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
  322. package/web/dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
  323. package/web/dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
  324. package/web/dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
  325. package/web/dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
  326. package/web/dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
  327. package/web/dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
  328. package/web/dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
  329. package/web/dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
  330. package/web/dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
  331. package/web/dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
  332. package/web/dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
  333. package/web/dist/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
  334. package/web/dist/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
  335. package/web/dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
  336. package/web/dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
  337. package/web/dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
  338. package/web/dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
  339. package/web/dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
  340. package/web/dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
  341. package/web/dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
  342. package/web/dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
  343. package/web/dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
  344. package/web/dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
  345. package/web/dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
  346. package/web/dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
  347. package/web/dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
  348. package/web/dist/assets/SiteWindow-D6eS-2gM.js +18 -0
  349. package/web/dist/assets/again-D8tfgKKO.webp +0 -0
  350. package/web/dist/assets/caveat-nd-CP6HlsNg.woff2 +0 -0
  351. package/web/dist/assets/clock-xJhvfYpS.webp +0 -0
  352. package/web/dist/assets/film-cut-BBKMwH7q.webp +0 -0
  353. package/web/dist/assets/film-still-BZ_DL9Tt.webp +0 -0
  354. package/web/dist/assets/index-C0KUf6Wd.css +1 -0
  355. package/web/dist/assets/index-Cbl3ATvp.js +2125 -0
  356. package/web/dist/assets/ink-desk-BRbxeDsR.webp +0 -0
  357. package/web/dist/assets/ink-night-Bz4BT3XH.webp +0 -0
  358. package/web/dist/assets/ink-portrait-C0DM4V8Q.webp +0 -0
  359. package/web/dist/assets/longcang-regular-C0uvTuKa.woff2 +0 -0
  360. package/web/dist/assets/lxgw-nd-bold-DbRfiTgo.woff2 +0 -0
  361. package/web/dist/assets/lxgw-nd-regular-CpMw9Ehg.woff2 +0 -0
  362. package/web/dist/assets/pending-edit-apply-DTjo2geW.js +31 -0
  363. package/web/dist/assets/question-8H4HSqxh.webp +0 -0
  364. package/web/dist/assets/reject-BaPJqElj.webp +0 -0
  365. package/web/dist/assets/rotate-cw-LMT3x1Zj.js +11 -0
  366. package/web/dist/assets/rp-portrait-CbrGpew2.webp +0 -0
  367. package/web/dist/assets/rp-street-C4jVKjxx.webp +0 -0
  368. package/web/dist/assets/square-dashed-mouse-pointer-CBoAkfhi.js +11 -0
  369. package/web/dist/assets/tangle-DIxHVNuT.webp +0 -0
  370. package/web/dist/assets/thisone-BylZKglV.webp +0 -0
  371. package/web/dist/assets/thumb-DO1wfK1I.webp +0 -0
  372. package/web/dist/index.html +18 -0
@@ -0,0 +1,1025 @@
1
+ /**
2
+ * server/projects/workspace.js — 项目工作区(2026-08-07 扁平化)
3
+ *
4
+ * **一个项目 = 一个工作区 = 一个目录。会话是对话线程,不是文件容器。**
5
+ *
6
+ * 结构:
7
+ * <PROJECTS_DATA_ROOT>/<projectId>/
8
+ * ├── shared/ ← **项目工作区**:agent 的 cwd,产物的家
9
+ * │ ├── .claude/
10
+ * │ │ ├── CLAUDE.md 项目指引(用户写)
11
+ * │ │ ├── settings.json 项目 SDK config
12
+ * │ │ ├── skills/ agents/ 项目级 skill / 子代理
13
+ * │ │ ├── agent-memory/ 长期记忆(agent 写)
14
+ * │ │ └── projects/ SDK 转录落点(.gitignore)
15
+ * │ ├── assets/ 上传素材 + 生成图
16
+ * │ ├── .nd/<sid>/ 会话私档(spec.json / design-plan.md)
17
+ * │ ├── .git/ 项目历史
18
+ * │ └── <产物…> canvas.html / index.html / notes/ …
19
+ * └── sessions/ 扁平化前的旧结构,迁移后只剩空壳(不删,留退路)
20
+ *
21
+ * ## 为什么 cwd 是工作区,不是 sessions/<sid>/(2026-08-07 改)
22
+ *
23
+ * 旧模型是三层:项目 → `shared/tasks/<任务>/` → 产物,会话跟任务一对一绑定。
24
+ * 线上 22 个项目的数据说这一层是空的:**13 个项目有任务,每个都恰好 1 个**,
25
+ * 没有任何一个项目有第二个任务。三个名字(项目 / 任务 / 会话)指同一样东西,
26
+ * 而代价是画布上要养一整套工作区几何(分区 / 格子 / 聚焦模式 / 文件夹卡),
27
+ * 落点被吸附到 244×210 的格子上 —— 用户能感觉到的就是"拖了不跟手"。
28
+ *
29
+ * 所以任务这一层整个退役。产物直接住工作区根,会话只剩"对话线程"这一个含义。
30
+ *
31
+ * 顺带解决的:cwd 就是工作区之后,`tasks/` `assets/` `skills/` `agents/`
32
+ * `agent-memory` 五条软链**全部不需要了**。那条写进 prelude 的老坑
33
+ * ("Glob/Grep 不跟软链,对 assets/* 返回空")跟着一起消失。
34
+ *
35
+ * ⚠️ 目录名仍叫 `shared/`:改名要动 22 个项目的磁盘路径,换不来任何功能。
36
+ * 它现在的含义是"**被所有会话共享的那个工作区**",字面上依然成立。
37
+ *
38
+ * 边界:
39
+ * - validateProjectId / validateSessionId 防 traversal
40
+ * - git ops 走 child_process spawn(不开 shell,args 不被 shell 解释)
41
+ */
42
+
43
+ import { promises as fs } from 'fs';
44
+ import { spawn } from 'child_process';
45
+ import path from 'path';
46
+ import { fileURLToPath } from 'url';
47
+ import { mutex } from 'async-mutex-lite';
48
+ import { validateProjectId } from './store.js';
49
+ import { resolveModelContextWindow } from '../engine/agent/model-context.js';
50
+
51
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
52
+
53
+ /**
54
+ * Per-sessionRoot read-modify-write 串行 utility。
55
+ *
56
+ * 解决:record_decision / expose_tweaks / PostCompact 3 处都 readFile→mutate→writeFile
57
+ * 同 spec.json,无锁 → 后写者 silent 覆盖前写者全量内容("刚 record 的 decision 没了"
58
+ * 类 bug)。三处统一通过本 helper 写,async-mutex-lite 是 module-singleton,所有
59
+ * import 共享 lock map,按 key 串行。
60
+ *
61
+ * @param {string} workspaceRoot - sessions/<sid>/ 路径
62
+ * @param {(spec: object) => (object|void|Promise<object|void>)} mutator
63
+ * 接收已 parse 的 spec object(不存在 / 解析失败时是 {}),同步或异步 mutate;
64
+ * return 的 object 当新 spec 写回(或 mutate 原 object 不 return 也行)。
65
+ * @returns {Promise<object>} 写入后的 spec
66
+ */
67
+ export async function mutateSpecJson(workspaceRoot, mutator) {
68
+ return mutex(`spec:${workspaceRoot}`, async () => {
69
+ const specPath = path.join(workspaceRoot, 'spec.json');
70
+ let spec = {};
71
+ try {
72
+ const raw = await fs.readFile(specPath, 'utf8');
73
+ spec = JSON.parse(raw);
74
+ if (!spec || typeof spec !== 'object') spec = {};
75
+ } catch { /* file not exist / parse error → fresh */ }
76
+ const next = (await mutator(spec)) || spec;
77
+ await fs.writeFile(specPath, JSON.stringify(next, null, 2), 'utf8');
78
+ return next;
79
+ });
80
+ }
81
+
82
+ export const PROJECTS_DATA_ROOT = path.resolve(
83
+ process.env.PROJECTS_DATA_DIR || path.join(__dirname, '../projects-data'),
84
+ );
85
+
86
+ const SESSION_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
87
+
88
+ /** SDK sessionId 必须 UUID 格式(防路径 traversal) */
89
+ export function validateSessionId(sid) {
90
+ if (typeof sid !== 'string' || !SESSION_ID_RE.test(sid)) {
91
+ throw Object.assign(new Error(`非法 sessionId: ${JSON.stringify(sid)}`), { code: 'INVALID_SESSION_ID' });
92
+ }
93
+ }
94
+
95
+ import {
96
+ DEFAULT_GITIGNORE, DEFAULT_SPEC_JSON, DEFAULT_CLAUDE_MD,
97
+ } from './workspace-templates.js';
98
+
99
+ /**
100
+ * NoDesign 全局默认 settings.json — 代码是 source of truth。
101
+ *
102
+ * 每次 ensureProjectWorkspace 都会跟 shared/.claude/settings.json merge
103
+ * (existing 字段优先,新增 default 字段补上)。这样升级现存 project 不需要
104
+ * 用户手动改文件。
105
+ *
106
+ * autoCompactEnabled / autoCompactWindow:
107
+ * 2026-05-01 加 — Kimi gateway 上下文上限 256k(262144 tokens)。当前默认
108
+ * 模型 kimi-k2.6 一旦 prompt 累积超 256k → gateway 直接 400 报错(用户实测
109
+ * request id 20260501104913995449543DV62Dl5F:requested 418547 tokens)。
110
+ * 按 256k × 90% = 230400 tokens 触发自动 compact,SDK 用同模型压缩对话历史。
111
+ * PostCompact hook(hooks.js:84)已就位,compact 后摘要写 spec.json 长期记忆。
112
+ *
113
+ * ⚠️ 历史坑(2026-05-08 修):SDK binary 内部 model registry 不识别 kimi-*,
114
+ * rawMaxTokens fallback 到 Anthropic 标准 200000,链式后果 maxTokens
115
+ * = min(autoCompactWindow=230400, rawMaxTokens=200000) = 200000,
116
+ * autoCompactWindow=230400 永远被卡 200k,浪费 60k+ Kimi gateway 真实容量。
117
+ * 修法:engine/agent/model-context.js 把 sdkOptions.model spoofing 成
118
+ * `claude-opus-4-7[1m]`(SDK 认 1M context),rawMaxTokens=1M 不再卡 230400;
119
+ * binary-fixup-proxy 在出口把 model 还原成真 kimi-k2.6 给 gateway。
120
+ * 现在 230400 真生效,SDK auto-compact 在 230k 触发,留 26k margin 防 400。
121
+ *
122
+ * 2026-07-27 起不再写死 230400:按 NODESIGN_MODEL 真实窗口 × 0.9 计算。
123
+ * sonnet-5[1m] → 900000(SDK 在此再扣内部 reserve,实际 compact 触发 ~86w);
124
+ * kimi-k2.6 → 230400(原值,256000 × 0.9);未知模型兜底 200000 × 0.9。
125
+ * SDK 接受 1e5 ~ 1e6 区间。切回 kimi 只需改 env 重启,默认值自动跟随。
126
+ * 旧项目 settings.json 里遗留的 230400 视为 stale default 强制迁移
127
+ * (mergeSettingsDefaults 内特判),用户手改过的其他值不动。
128
+ */
129
+ const MAIN_MODEL = process.env.NODESIGN_MODEL || 'claude-sonnet-5[1m]'; // 08-21 深夜:kimi 行删了,兜底改订阅默认行(同 session-model.js)
130
+ const LEGACY_DEFAULT_WINDOWS = new Set([230400]);
131
+
132
+ function defaultAutoCompactWindow() {
133
+ const realWindow = resolveModelContextWindow(MAIN_MODEL) ?? 200_000;
134
+ return Math.min(Math.round(realWindow * 0.9), 1_000_000);
135
+ }
136
+
137
+ const DEFAULT_NODESIGN_SETTINGS = {
138
+ $schema: 'https://json.schemastore.org/claude-code-settings.json',
139
+ autoCompactEnabled: true,
140
+ autoCompactWindow: defaultAutoCompactWindow(),
141
+ };
142
+
143
+ /**
144
+ * Merge NoDesign defaults 到现存 settings.json(existing 字段优先)。
145
+ * 文件不存在时直接落 defaults。
146
+ *
147
+ * @returns {Promise<boolean>} 是否有改动(true = 写入了,false = 完全相同跳过)
148
+ */
149
+ async function mergeSettingsDefaults(settingsPath) {
150
+ let existing = {};
151
+ if (await fileExists(settingsPath)) {
152
+ try {
153
+ existing = JSON.parse(await fs.readFile(settingsPath, 'utf8'));
154
+ } catch (err) {
155
+ // 损坏的 JSON:保留备份后用 defaults 覆盖
156
+ const backup = settingsPath + `.broken-${Date.now()}`;
157
+ await fs.rename(settingsPath, backup).catch(() => {});
158
+ console.warn(`[workspace] settings.json parse failed, backed up to ${backup}`);
159
+ existing = {};
160
+ }
161
+ }
162
+ const merged = { ...DEFAULT_NODESIGN_SETTINGS, ...existing };
163
+ // stale default 迁移:旧代码把 230400 写进过所有项目的 settings.json,
164
+ // existing 优先的 merge 规则会让新默认值永远进不去 —— 命中旧默认值时视为
165
+ // "非用户自定义",跟随当前默认。用户改成其他数字则尊重不动。
166
+ if (
167
+ LEGACY_DEFAULT_WINDOWS.has(existing.autoCompactWindow) &&
168
+ existing.autoCompactWindow !== DEFAULT_NODESIGN_SETTINGS.autoCompactWindow
169
+ ) {
170
+ merged.autoCompactWindow = DEFAULT_NODESIGN_SETTINGS.autoCompactWindow;
171
+ }
172
+ // 旧 _comment 字段不再写默认(曾经的 placeholder),用户自定义保留
173
+ const before = JSON.stringify(existing);
174
+ const after = JSON.stringify(merged);
175
+ if (before === after) return false;
176
+ await fs.writeFile(settingsPath, JSON.stringify(merged, null, 2) + '\n', 'utf8');
177
+ return true;
178
+ }
179
+
180
+ // ── 路径 helpers ──
181
+
182
+ /** 项目数据目录(容器:里面装 shared/ 和迁移前遗留的 sessions/) */
183
+ export function getProjectWorkspace(projectId) {
184
+ validateProjectId(projectId);
185
+ return path.join(PROJECTS_DATA_ROOT, projectId);
186
+ }
187
+
188
+ /**
189
+ * **项目工作区根** —— agent 的 cwd,产物的家,画布上看到的一切的真相。
190
+ *
191
+ * 这是扁平化之后唯一有意义的"工作目录"概念。旧名 `getSharedDir` 继续可用
192
+ * (40+ 处调用,含义没变)。
193
+ */
194
+ export function getWorkspaceRoot(projectId) {
195
+ return path.join(getProjectWorkspace(projectId), 'shared');
196
+ }
197
+
198
+ /** 旧名,等价于 getWorkspaceRoot */
199
+ export const getSharedDir = getWorkspaceRoot;
200
+
201
+ /**
202
+ * 会话工作目录 = 项目工作区。**sessionId 只用来校验,不参与路径**。
203
+ *
204
+ * 保留这个名字是因为它是 SDK 侧 `cwd` 的取值口(转录目录 encodeCwdForSDK(cwd)
205
+ * 从它算),28 处调用全都是"给这个会话一个 cwd"的意思 —— 现在这个答案对每个
206
+ * 会话都一样,那正是"产物与 session 脱钩"。
207
+ */
208
+ export function getSessionWorkspace(projectId, sessionId) {
209
+ validateSessionId(sessionId);
210
+ return getWorkspaceRoot(projectId);
211
+ }
212
+
213
+ /**
214
+ * 会话私档目录(`<工作区>/.nd/<sid>/`):spec.json(压缩摘要)、design-plan.md。
215
+ *
216
+ * 这些属于**对话**不属于产物,所以既不上画布也不进 git,但必须留在 cwd 内 ——
217
+ * 放 cwd 外就得靠 additionalDirectories + 绝对路径,而 agent 看不见仓库路径。
218
+ */
219
+ export function getSessionMetaDir(projectId, sessionId) {
220
+ validateSessionId(sessionId);
221
+ return path.join(getWorkspaceRoot(projectId), '.nd', sessionId);
222
+ }
223
+
224
+ // ── ensure ──
225
+
226
+ /**
227
+ * 创建项目工作区(幂等)。完成后保证:
228
+ * - .claude/{CLAUDE.md, settings.json} 模板写入(仅不存在时)
229
+ * - .claude/{skills, agents, agent-memory} 目录存在
230
+ * - assets/ 存在、.gitignore 写入
231
+ * - 旧的三层结构(tasks/ + per-session 沙盒)已扁平化
232
+ * - .git 存在(项目级历史;扁平化前是 per-session 的)
233
+ *
234
+ * 扁平化跟着 ensure 走而不是单独一次性脚本:跟 removeRootLegacyArtifacts 同一个
235
+ * 范式 —— 幂等、按项目惰性触发、跑过一次之后是 no-op。这样线上不需要停机窗口,
236
+ * 也不存在"迁移脚本漏了哪个项目"。
237
+ */
238
+ export async function ensureProjectWorkspace(projectId) {
239
+ await removeRootLegacyArtifacts(projectId);
240
+
241
+ const root = getWorkspaceRoot(projectId);
242
+ await fs.mkdir(path.join(root, '.claude', 'skills'), { recursive: true });
243
+ await fs.mkdir(path.join(root, '.claude', 'agents'), { recursive: true });
244
+ await fs.mkdir(path.join(root, '.claude', 'agent-memory'), { recursive: true });
245
+ await fs.mkdir(path.join(root, 'assets'), { recursive: true });
246
+
247
+ // .gitignore 每次比对而不是"不存在才写":扁平化新增了 .claude/projects/ 和
248
+ // .nd/ 两条,老项目的文件里没有,不补的话 SDK 转录会被 commit 进项目历史。
249
+ await ensureGitignore(path.join(root, '.gitignore'));
250
+ if (!(await fileExists(path.join(root, '.claude', 'CLAUDE.md')))) {
251
+ await fs.writeFile(path.join(root, '.claude', 'CLAUDE.md'), DEFAULT_CLAUDE_MD, 'utf8');
252
+ }
253
+ // settings.json:每次 merge defaults 让代码层 default 升级时现存 project 自动跟上
254
+ // (用户字段优先,缺失的 NoDesign default 字段补进去)
255
+ await mergeSettingsDefaults(path.join(root, '.claude', 'settings.json'));
256
+
257
+ await flattenWorkspace(projectId);
258
+
259
+ // 返回**工作区根**(…/shared),跟 ensureSessionWorkspace 一致(2026-08-13)。
260
+ // 以前返回的是项目目录(shared 的上一层)——两个 ensure 返回值差一层目录,
261
+ // 而 api 层的 rootOf 模式(pending-changes.js 首创)把两者当同一个 sessionRoot
262
+ // 用:sid 走 ensureSessionWorkspace、项目级走这里。不统一的话项目级路由会把
263
+ // pending-changes.json 之类写到 shared 外面,agent(读工作区根)永远看不见。
264
+ // 改这里而不是改每个调用方:全仓只有 rootOf 消费这个返回值,其余 10 处都是
265
+ // 纯 await 副作用。
266
+ return getWorkspaceRoot(projectId);
267
+ }
268
+
269
+ /**
270
+ * .gitignore:保证 DEFAULT_GITIGNORE 里每一条都在,用户自己加的行原样保留。
271
+ * 按行合并而不是整文件覆盖 —— 有人会往里加自己的规则。
272
+ */
273
+ async function ensureGitignore(file) {
274
+ let existing = '';
275
+ try { existing = await fs.readFile(file, 'utf8'); } catch { /* 还没有 */ }
276
+ const have = new Set(existing.split('\n').map(l => l.trim()));
277
+ const missing = DEFAULT_GITIGNORE.split('\n').filter(l => l.trim() && !have.has(l.trim()));
278
+ if (!missing.length && existing) return;
279
+ const merged = existing
280
+ ? `${existing.replace(/\n*$/, '\n')}${missing.join('\n')}\n`
281
+ : DEFAULT_GITIGNORE;
282
+ await fs.writeFile(file, merged, 'utf8');
283
+ }
284
+
285
+ /**
286
+ * 备好一个会话能开跑的一切(幂等)。返回**项目工作区根** = 这个会话的 cwd。
287
+ *
288
+ * 扁平化之后这里几乎没事干了,值得记一笔它以前干什么:建 `sessions/<sid>/`
289
+ * 沙盒、拉五条绝对软链(.claude/CLAUDE.md、settings.json、skills、agents、
290
+ * agent-memory、assets、tasks)、init 一个 per-session git。那一整套的存在
291
+ * 理由只有一个 —— **cwd 不是工作区**,所以工作区里的东西得一条条链进来。
292
+ *
293
+ * cwd 就是工作区之后:软链零条(连带 bwrap 目录型软链冲突、Glob 不跟软链两个
294
+ * 老坑一起消失),git 变成项目级一个仓(在 ensureProjectWorkspace 里 init)。
295
+ *
296
+ * @returns {Promise<string>} 项目工作区绝对路径(agent 的 cwd)
297
+ */
298
+ export async function ensureSessionWorkspace(projectId, sessionId) {
299
+ await ensureProjectWorkspace(projectId);
300
+ const root = getWorkspaceRoot(projectId);
301
+ // SDK 转录落点。它在 cwd/.claude/projects/<encoded-cwd>/<sid>.jsonl,
302
+ // cwd 收敛成项目工作区之后,一个项目的所有会话转录并排住在同一个目录里
303
+ // (这正是 Claude Code 自己的形状)。
304
+ await fs.mkdir(path.join(root, '.claude', 'projects'), { recursive: true });
305
+ await fs.mkdir(getSessionMetaDir(projectId, sessionId), { recursive: true });
306
+ return root;
307
+ }
308
+
309
+ /**
310
+ * 项目级 git(幂等)。扁平化前这是 per-session 的,
311
+ * 现在一个项目一个仓 —— 产物归项目,历史当然也归项目。
312
+ */
313
+ async function ensureProjectGit(root) {
314
+ if (await fileExists(path.join(root, '.git'))) {
315
+ // board.json 2026-08-08 才进 gitignore,而 gitignore 对**已经被跟踪**的
316
+ // 文件不起作用 —— 得显式从索引里摘一次。幂等:没被跟踪时 rm 会失败,吞掉。
317
+ await runGit(root, ['rm', '--cached', '-q', '--ignore-unmatch', 'board.json']).catch(() => {});
318
+ return;
319
+ }
320
+ await runGit(root, ['init', '-q', '-b', 'main']);
321
+ await runGit(root, ['add', '-A']);
322
+ await runGit(root, [
323
+ '-c', 'user.email=nodesign@local',
324
+ '-c', 'user.name=NoDesign',
325
+ 'commit', '-q', '--allow-empty', '-m', 'init',
326
+ ]);
327
+ }
328
+
329
+ // ── 扁平化迁移(2026-08-07)────────────────────────────────────────────
330
+
331
+ /** 迁移后旧结构改叫这个名字。留着不删 = 出事能退回去,而且给幂等一个干净的信号 */
332
+ const PRE_FLATTEN_DIR = 'sessions.pre-flatten';
333
+
334
+ /**
335
+ * 真正属于**一次对话**的文件,扁平化后住 `.nd/<sid>/`。
336
+ *
337
+ * 这张表是踩出来的,不是想出来的:第一版只列了 spec.json 和 design-plan.md,
338
+ * 拿真数据跑迁移时逐文件对账才发现另外两个 —— `session-config.json`(这条
339
+ * 会话选的模型)和 `pending-changes.json`(画布上还没交给 agent 的改动)。
340
+ * 这两个要是跟着别的东西一起摊平到工作区根,**两条会话会共用一份**:
341
+ * 一边换模型另一边跟着变,一边的待处理改动被另一边 clear 掉。
342
+ */
343
+ const SESSION_PRIVATE_FILES = [
344
+ 'spec.json',
345
+ 'design-plan.md',
346
+ 'session-config.json',
347
+ 'pending-changes.json',
348
+ ];
349
+
350
+ /**
351
+ * SDK 把 cwd 编码成 `<config>/projects/<encoded>/` —— 非字母数字一律换 '-'。
352
+ * (算法 grep 自 sdk.mjs。)扁平化要搬转录,sessions.js 要按它找 jsonl,
353
+ * 两边必须是同一个函数,所以放在这里当唯一真相。
354
+ */
355
+ export function encodeCwdForSDK(cwd) {
356
+ return cwd.replace(/[^a-zA-Z0-9]/g, '-');
357
+ }
358
+
359
+ /**
360
+ * 三层(项目 → 任务 → 产物)扁平成两层(项目 → 产物)。**幂等**。
361
+ *
362
+ * 跑过之后 `sessions/` 改名成 `sessions.pre-flatten/`,下次进来一次 stat 就返回。
363
+ *
364
+ * 搬七样东西:
365
+ * 1. `tasks/<任务>/*` → 工作区根。**只有一个任务时摊平**(线上 13 个有任务的
366
+ * 项目全是这种),多个任务时各自变成一个顶层文件夹 —— 那样绝不撞名,
367
+ * html 里的相对引用也原封不动。
368
+ * 2. 任务自己的 `.git`(旧形态留下的)→ 工作区的 `.git`,历史不丢。
369
+ * 3. `.nd-task.json` 删掉:它记的是"这个任务属于哪个会话",正是要废的那条绑定。
370
+ * 4. `sessions/<sid>/{spec.json, design-plan.md}` → `.nd/<sid>/`。
371
+ * 5. `sessions/<sid>/canvas.html`(旧式单 deck 会话)→ 工作区根。
372
+ * 6. **SDK 转录**:cwd 变了,encoded 目录跟着变,不搬的话每条会话的历史全部
373
+ * 失联。这是整个迁移里唯一不可逆的损失点,所以搬之前先确认目标不存在。
374
+ * 7. `board.json` 的物件 id / 关系线端点重写,zones 整个丢掉。
375
+ *
376
+ * @returns {Promise<boolean>} 这次是否真的迁了(false = 早就迁过了)
377
+ */
378
+ export async function flattenWorkspace(projectId) {
379
+ const root = getWorkspaceRoot(projectId);
380
+ const container = getProjectWorkspace(projectId);
381
+ const sessionsDir = path.join(container, 'sessions');
382
+ const tasksDir = path.join(root, 'tasks');
383
+
384
+ const hasTasks = await pathExists(tasksDir);
385
+ const hasSessions = await pathExists(sessionsDir);
386
+ if (!hasTasks && !hasSessions) {
387
+ await ensureProjectGit(root);
388
+ return false;
389
+ }
390
+
391
+ return mutex(`flatten:${root}`, async () => {
392
+ // 拿到锁再查一遍 —— 等锁那会儿别人可能已经迁完了
393
+ if (!(await pathExists(tasksDir)) && !(await pathExists(sessionsDir))) return false;
394
+
395
+ const log = [];
396
+ const renames = new Map(); // 老物件 id → 新物件 id(board.json 用)
397
+
398
+ // ① 任务目录上移一层:`tasks/<名>/` → `<名>/`
399
+ //
400
+ // **文件夹一律保留**,哪怕项目里只有一个。2026-08-07 那版在只有一个任务时
401
+ // 会把内容摊平到工作区根(线上 13 个有产物的项目正好都是这种),当时的理由
402
+ // 是「三个名字指同一样东西」。那个判断被推翻了:文件夹要升级成能嵌套、能
403
+ // 自由摆放的一等公民,摊平等于把用户仅有的那个收纳容器也拆了。
404
+ // 去掉的只有 `tasks/` 这一层中间目录,不是文件夹本身。
405
+ if (await pathExists(tasksDir)) {
406
+ const entries = await fs.readdir(tasksDir, { withFileTypes: true });
407
+ const taskNames = entries.filter(e => e.isDirectory() && !e.name.startsWith('.')).map(e => e.name);
408
+ for (const name of taskNames) {
409
+ const src = path.join(tasksDir, name);
410
+ const dest = path.join(root, name);
411
+ // 根上已经有同名东西才需要逐条合并;正常情况一次 rename 搞定,
412
+ // 既快又不碰文件内容(撞名走 mergeDir,它会逐字节比对再决定)
413
+ if (await pathExists(dest)) await mergeDir(src, dest, log);
414
+ else await fs.rename(src, dest);
415
+ await retireTaskMarker(dest, log);
416
+ await fixEscapingRelativePaths(dest, log);
417
+ renames.set(`tasks/${name}/`, `${name}/`);
418
+ renames.set(`task/${name}`, name);
419
+ }
420
+ await fs.rm(tasksDir, { recursive: true, force: true });
421
+ log.push(`tasks/ 这一层去掉(${taskNames.length} 个文件夹上移到工作区根)`);
422
+ }
423
+ await retireTaskMarker(root, log);
424
+
425
+ // ④ ⑤ ⑥ 会话目录
426
+ if (await pathExists(sessionsDir)) {
427
+ const sids = (await fs.readdir(sessionsDir, { withFileTypes: true }))
428
+ .filter(e => e.isDirectory() && SESSION_ID_RE.test(e.name)).map(e => e.name);
429
+ for (const sid of sids) {
430
+ const sRoot = path.join(sessionsDir, sid);
431
+ const meta = path.join(root, '.nd', sid);
432
+ await fs.mkdir(meta, { recursive: true });
433
+ for (const f of SESSION_PRIVATE_FILES) {
434
+ await moveFile(path.join(sRoot, f), path.join(meta, f), log);
435
+ }
436
+ // 旧式单 deck 会话的产物、以及 skill 拷进 cwd 的起手模板:都归工作区
437
+ for (const f of ['canvas.html', 'canvas.template.html', 'site.template.html', 'style.template.css']) {
438
+ await moveFile(path.join(sRoot, f), path.join(root, f), log);
439
+ }
440
+ // export_handoff 的落点:产物性质,归项目
441
+ if (await pathExists(path.join(sRoot, 'exports'))) {
442
+ await fs.mkdir(path.join(root, 'exports'), { recursive: true });
443
+ await mergeDir(path.join(sRoot, 'exports'), path.join(root, 'exports'), log);
444
+ }
445
+ await moveTranscripts(sRoot, root, sid, log);
446
+ }
447
+ await fs.rename(sessionsDir, path.join(container, PRE_FLATTEN_DIR)).catch(async (err) => {
448
+ if (err.code !== 'ENOTEMPTY' && err.code !== 'EEXIST') throw err;
449
+ // 已经有一份存档(迁移跑到一半重来过)→ 旧的那份留着,这次的丢进去
450
+ await fs.rm(sessionsDir, { recursive: true, force: true });
451
+ });
452
+ log.push(`${sids.length} 个会话的私档与转录已归位`);
453
+ }
454
+
455
+ // ⑦ board.json
456
+ await rewriteBoardIds(path.join(root, 'board.json'), renames, log);
457
+
458
+ await ensureProjectGit(root);
459
+ console.log(`[flatten] ${projectId}\n ${log.join('\n ')}`);
460
+ return true;
461
+ });
462
+ }
463
+
464
+ /**
465
+ * 递归合并 src 的内容进 dest。
466
+ *
467
+ * 撞名策略:**字节相同就丢掉来的那份**(线上唯一一处撞名是 agent 把生成图从
468
+ * assets/ 拷了一份进任务目录,7 个文件逐字节相同)。真不一样才两份都留,
469
+ * 来的那份加后缀 —— 并且**大声报出来**,因为改名会让 html 里的引用指空。
470
+ */
471
+ async function mergeDir(src, dest, log) {
472
+ for (const e of await fs.readdir(src, { withFileTypes: true })) {
473
+ const from = path.join(src, e.name);
474
+ const to = path.join(dest, e.name);
475
+ if (e.isDirectory()) {
476
+ if (await pathExists(to)) {
477
+ await fs.mkdir(to, { recursive: true });
478
+ await mergeDir(from, to, log);
479
+ await fs.rm(from, { recursive: true, force: true });
480
+ } else {
481
+ await fs.rename(from, to);
482
+ }
483
+ continue;
484
+ }
485
+ if (!(await pathExists(to))) { await fs.rename(from, to); continue; }
486
+ if (await sameFile(from, to)) { await fs.rm(from, { force: true }); continue; }
487
+ const ext = path.extname(e.name);
488
+ const alt = path.join(dest, `${path.basename(e.name, ext)}-任务版${ext}`);
489
+ await fs.rename(from, alt);
490
+ log.push(`⚠️ 撞名且内容不同:${e.name} → ${path.basename(alt)}(引用它的地方要改)`);
491
+ }
492
+ }
493
+
494
+ /**
495
+ * 旧任务标记 `.nd-task.json` → 新产物标记 `.nd-project.json`。
496
+ *
497
+ * 旧标记里有三样:`sessionId` / `boundAt`("这个任务属于哪次对话" —— 正是这次
498
+ * 要废掉的绑定,不带走)和 `kind`(形态兜底,还有用)。`root`(构建型站点显式
499
+ * 声明产物根)线上数据里一个都没有,但真出现了也一并带走,它比 kind 更要紧。
500
+ *
501
+ * 不能只删不转:形态判定是「文件即真相,marker 兜底」,兜底那一支塌了的话,
502
+ * 刚建好还没写入口文件的空文件夹会认不出形态。
503
+ */
504
+ async function retireTaskMarker(dir, log) {
505
+ const old = path.join(dir, '.nd-task.json');
506
+ let parsed = null;
507
+ try { parsed = JSON.parse(await fs.readFile(old, 'utf8')); } catch { return; }
508
+ const keep = {};
509
+ if (typeof parsed?.kind === 'string') keep.kind = parsed.kind;
510
+ if (typeof parsed?.root === 'string') keep.root = parsed.root;
511
+ const next = path.join(dir, '.nd-project.json');
512
+ if (Object.keys(keep).length && !(await pathExists(next))) {
513
+ await fs.writeFile(next, JSON.stringify(keep, null, 2), 'utf8');
514
+ log.push(`${path.basename(dir)}/.nd-task.json → .nd-project.json(留下 ${Object.keys(keep).join('+')},去掉会话归属)`);
515
+ }
516
+ await fs.rm(old, { force: true });
517
+ }
518
+
519
+ /**
520
+ * 文件夹上移一层之后,修 HTML/CSS 里**爬出文件夹**的相对路径。
521
+ *
522
+ * 这是这次迁移唯一会**静默损坏内容**的地方,实测抓到的:
523
+ * `tasks/Space-Colony/_drafts/proto.html` 里写着 `../../../assets/generated/x.webp`
524
+ * —— 老位置深三层,爬三下正好到 `shared/assets/`。上移之后只剩两层,
525
+ * 同样爬三下就爬到工作区外面,图全部 404,而页面照常渲染,没有任何报错。
526
+ *
527
+ * 判据是「这条引用有没有爬出它自己那个文件夹」:
528
+ * 文件在文件夹内的深度 d(`<T>/f.html` → 0,`<T>/_drafts/f.html` → 1)
529
+ * 引用的 `../` 个数 k
530
+ * k > d = 爬出去了 → 少爬一层(文件夹整体上移了一层,外面的东西近了一层)
531
+ * k ≤ d = 还在文件夹里面 → 一个字节都不动(文件和目标一起搬的,相对关系没变)
532
+ */
533
+ async function fixEscapingRelativePaths(dir, log, depth = 0) {
534
+ let entries = [];
535
+ try { entries = await fs.readdir(dir, { withFileTypes: true }); } catch { return 0; }
536
+ let fixed = 0;
537
+ for (const e of entries) {
538
+ const p = path.join(dir, e.name);
539
+ if (e.isDirectory()) {
540
+ if (e.name === 'node_modules' || e.name.startsWith('.')) continue;
541
+ fixed += await fixEscapingRelativePaths(p, log, depth + 1);
542
+ continue;
543
+ }
544
+ if (!/\.(html?|css|js)$/i.test(e.name)) continue;
545
+ let src;
546
+ try { src = await fs.readFile(p, 'utf8'); } catch { continue; }
547
+ // 只认跟在引号 / url( / 空白后面的那种,避免动到正文里偶然出现的 "../"
548
+ const next = src.replace(/(["'(\s=])((?:\.\.\/)+)/g, (m, lead, dots) => {
549
+ const k = dots.length / 3;
550
+ if (k <= depth) return m; // 没爬出这个文件夹
551
+ return lead + '../'.repeat(k - 1);
552
+ });
553
+ if (next === src) continue;
554
+ try { await fs.writeFile(p, next, 'utf8'); fixed += 1; } catch { /* 只读文件,跳过 */ }
555
+ }
556
+ if (fixed && depth === 0) log.push(`${path.basename(dir)}/ 里 ${fixed} 个文件的相对路径少爬了一层`);
557
+ return fixed;
558
+ }
559
+
560
+ async function sameFile(a, b) {
561
+ try {
562
+ const [sa, sb] = await Promise.all([fs.stat(a), fs.stat(b)]);
563
+ if (sa.size !== sb.size) return false;
564
+ const [ba, bb] = await Promise.all([fs.readFile(a), fs.readFile(b)]);
565
+ return ba.equals(bb);
566
+ } catch { return false; }
567
+ }
568
+
569
+ async function moveFile(from, to, log) {
570
+ if (!(await pathExists(from)) || await pathExists(to)) return;
571
+ await fs.rename(from, to);
572
+ log.push(`${path.basename(from)} → ${path.relative(path.dirname(path.dirname(to)), to)}`);
573
+ }
574
+
575
+ /**
576
+ * SDK 转录搬家:`<config>/projects/<encode(老 cwd)>/*.jsonl`
577
+ * → `<config>/projects/<encode(新 cwd)>/`
578
+ *
579
+ * 不搬 = 每条会话打开是空白。已存在同名就跳过(不覆盖,宁可留在老目录里)。
580
+ */
581
+ async function moveTranscripts(oldCwd, newCwd, sid, log) {
582
+ const base = path.join(claudeConfigDir(), 'projects');
583
+ const from = path.join(base, encodeCwdForSDK(oldCwd));
584
+ const to = path.join(base, encodeCwdForSDK(newCwd));
585
+ if (!(await pathExists(from))) return;
586
+ await fs.mkdir(to, { recursive: true });
587
+ let moved = 0;
588
+ for (const e of await fs.readdir(from, { withFileTypes: true })) {
589
+ if (!e.isFile() || !e.name.endsWith('.jsonl')) continue;
590
+ const dst = path.join(to, e.name);
591
+ if (await pathExists(dst)) continue;
592
+ await fs.rename(path.join(from, e.name), dst);
593
+ moved += 1;
594
+ }
595
+ if (moved) log.push(`转录 ${moved} 份 → ${sid.slice(0, 8)}`);
596
+ }
597
+
598
+ /** 延迟取(platform.js 读 env,import 时机比这里早不了多少,但别在模块顶层固化) */
599
+ function claudeConfigDir() {
600
+ return process.env.NODESIGN_CONFIG_DIR
601
+ || path.join(process.env.HOME || process.env.USERPROFILE || '', '.claude');
602
+ }
603
+
604
+ /**
605
+ * board.json 的物件 id 重写 + zones 丢弃。
606
+ *
607
+ * id 里的任务段直接消失:
608
+ * `tasks/<t>/notes/a.md` → `notes/a.md` (文件型物件 = 相对路径)
609
+ * `deck:task/<t>` → `deck:canvas.html`
610
+ * `deck:task/<t>/x.html` → `deck:x.html`
611
+ * `site:task/<t>` → `site:.` (`.` = 产物根就是工作区根)
612
+ * `site:task/<t>/v2` → `site:v2`
613
+ * `deck:<会话uuid>` → 丢弃(会话 deck 这个概念随绑定一起废)
614
+ *
615
+ * 关系线的两个端点用同一张表改,改完两端还在才留 —— 否则线会挂在空气上。
616
+ */
617
+ async function rewriteBoardIds(file, renames, log) {
618
+ let board;
619
+ try { board = JSON.parse(await fs.readFile(file, 'utf8')); } catch { return; }
620
+ if (!board || typeof board !== 'object') return;
621
+
622
+ const mapId = (id) => {
623
+ if (typeof id !== 'string') return null;
624
+ if (SESSION_DECK_RE.test(id)) return null; // 会话 deck 退役
625
+ const m = id.match(/^(deck|site):task\/([^/]+)(?:\/(.*))?$/);
626
+ if (m) {
627
+ const [, type, task, rest] = m;
628
+ const seat = renames.get(`task/${task}`);
629
+ // 磁盘上没有这个任务了(board.json 里的陈年孤儿,指向的文件早就不在)。
630
+ // 迁移前它就已经是死的,顺手清掉而不是搬到根上假装还活着。
631
+ if (!seat) return null;
632
+ const under = (p) => `${seat}/${p}`;
633
+ if (type === 'deck') return `deck:${under(rest || 'canvas.html')}`;
634
+ return `site:${rest ? under(rest) : seat}`;
635
+ }
636
+ for (const [oldPrefix, newPrefix] of renames) {
637
+ if (oldPrefix.endsWith('/') && id.startsWith(oldPrefix)) {
638
+ return newPrefix + id.slice(oldPrefix.length);
639
+ }
640
+ }
641
+ // 还带着 `tasks/` 前缀却没匹配上任何一条改名 = 指向一个磁盘上早就没有的
642
+ // 任务(board.json 里的陈年孤儿,实测 3wgl 有两条指向删掉的 shelter/)。
643
+ // 迁移前它就不渲染,留着只会变成一条永远对不上号的路径。
644
+ if (id.startsWith('tasks/')) return null;
645
+ return id;
646
+ };
647
+
648
+ /**
649
+ * 分区 → 文件夹。**不丢弃**(2026-08-08 改)。
650
+ *
651
+ * 上一版这里是 `delete next.zones` —— 那时的方向是分区整个体系退役。方向变了:
652
+ * 分区降级成文件夹,是能嵌套、能自由摆放的一等公民,它在画布上的矩形、标题、
653
+ * 收起状态都还要用。id 从 `task/<名>` 变成文件夹的工作区相对路径 `<名>`。
654
+ *
655
+ * 会话分区(id 是 sessionId 的那些,任务模型之前的遗产)没有对应的文件夹,
656
+ * 照旧丢 —— 它们背后没有任何磁盘目录,留着就是永远删不掉的僵尸卡。
657
+ */
658
+ const mapZoneId = (id) => {
659
+ if (typeof id !== 'string') return null;
660
+ if (!id.startsWith('task/')) return null;
661
+ return renames.get(id) ?? null;
662
+ };
663
+
664
+ const objects = {};
665
+ for (const [id, o] of Object.entries(board.objects || {})) {
666
+ const next = mapId(id);
667
+ if (next && !objects[next]) objects[next] = o;
668
+ }
669
+ const zones = {};
670
+ for (const [id, z] of Object.entries(board.zones || {})) {
671
+ const next = mapZoneId(id);
672
+ if (next && !zones[next]) zones[next] = z;
673
+ }
674
+ // 关系线的端点可以是物件,也可以是文件夹 —— 两种都要跟着改名。
675
+ // **先判形状再解**:`task/<名>` 落进 mapId 会走到末尾那句 `return id` 被
676
+ // 原样放行(既不匹配 `deck:` 那条正则,也不匹配任何以 '/' 结尾的前缀),
677
+ // 于是永远轮不到 mapZoneId,文件夹端点就留在旧 id 上成了断头线。
678
+ const mapEnd = (id) => (typeof id === 'string' && id.startsWith('task/') ? mapZoneId(id) : mapId(id));
679
+ const bindings = {};
680
+ for (const [id, b] of Object.entries(board.bindings || {})) {
681
+ const from = mapEnd(b?.from);
682
+ const to = mapEnd(b?.to);
683
+ if (from && to && from !== to) bindings[id] = { ...b, from, to };
684
+ }
685
+ await fs.writeFile(file, JSON.stringify({ ...board, objects, zones, bindings }), 'utf8');
686
+ log.push(`board.json:物件 ${Object.keys(board.objects || {}).length} → ${Object.keys(objects).length}`
687
+ + `,文件夹 ${Object.keys(board.zones || {}).length} → ${Object.keys(zones).length}`
688
+ + `,关系 ${Object.keys(board.bindings || {}).length} → ${Object.keys(bindings).length}`);
689
+ }
690
+
691
+ const SESSION_DECK_RE = /^deck:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
692
+
693
+ // ── workspace 主动提醒(C8 SKILL/prelude 改造)──
694
+ //
695
+ // 给 turn.js composeUserMessage 用 —— 检测 sessionRoot 下 assets/ 软链指向的
696
+ // shared/assets/ 是否有内容,有就让 agent 看见 "<system>workspace 里有 N 个文
697
+ // 件……" 提示。空目录就不注入,agent 不必每个 turn 都硬 Glob 一遍。
698
+ //
699
+ // 之前的设计:prelude 强制 agent 首跑前 Glob assets/**/* —— 浪费一次 turn 即便
700
+ // 目录是空的。改成 workspace 主动 prepend 提示,把"是否需要看 assets" 这个
701
+ // 决策从"agent 必须先做"翻译成"agent 看到提示自己判断"。
702
+
703
+ // Office / PDF:二进制或 OOXML zip 包,Read 直接看是字节流,需 python 解
704
+
705
+
706
+ // ── 老结构清理(用户决策"删了")──
707
+
708
+ /**
709
+ * 检测 project workspace 根有 canvas.html / spec.json / .git / .claude 这些
710
+ * S1 时代的老 artifacts,且 shared/ 不存在 → 这是老结构 → 全删。
711
+ *
712
+ * 运行一次性,每个 project 第一次进入新代码时清理。idempotent。
713
+ */
714
+ export async function removeRootLegacyArtifacts(projectId) {
715
+ const root = getProjectWorkspace(projectId);
716
+ if (!(await fileExists(root))) return;
717
+ if (await fileExists(path.join(root, 'shared'))) return;
718
+
719
+ // 只有当老 artifacts 至少一个存在时,认定是老 project
720
+ const legacyTargets = ['canvas.html', 'spec.json', '.git', '.gitignore', '.claude', 'assets'];
721
+ let hadLegacy = false;
722
+ for (const name of legacyTargets) {
723
+ if (await fileExists(path.join(root, name))) { hadLegacy = true; break; }
724
+ }
725
+ if (!hadLegacy) return;
726
+
727
+ for (const name of legacyTargets) {
728
+ const p = path.join(root, name);
729
+ if (await fileExists(p)) {
730
+ await fs.rm(p, { recursive: true, force: true });
731
+ }
732
+ }
733
+ console.log(`[workspace] removed legacy root artifacts for ${projectId}`);
734
+ }
735
+
736
+ // ── 删除 ──
737
+
738
+ export async function removeProjectWorkspace(projectId) {
739
+ const root = getProjectWorkspace(projectId);
740
+ await fs.rm(root, { recursive: true, force: true });
741
+ }
742
+
743
+ /**
744
+ * 删一个会话留下的东西 —— **只有它的私档**(`.nd/<sid>/`)。
745
+ *
746
+ * ⚠️ 扁平化之前这里是 `rm -rf sessions/<sid>/`,那时候产物住在会话目录里,
747
+ * 所以"删会话"顺带删掉产物是对的。现在产物归项目,删对话不能动产物 ——
748
+ * 这个函数要是照抄旧实现(删 getSessionWorkspace 返回的目录),删的就是
749
+ * **整个项目工作区**。
750
+ */
751
+ export async function removeSessionWorkspace(projectId, sessionId) {
752
+ await fs.rm(getSessionMetaDir(projectId, sessionId), { recursive: true, force: true });
753
+ }
754
+
755
+ // ── git ops(per-session)──
756
+
757
+ /**
758
+ * commit 项目工作区。无改动 silent skip。
759
+ *
760
+ * sessionId 现在只是**记在 commit 信息里的出处**(哪次对话干的),不再决定
761
+ * 提交到哪个仓 —— 一个项目一个仓。两个会话同时收尾也不会打架:mutex 的 key
762
+ * 是工作区路径,本来就串行。
763
+ */
764
+ export async function commitWorkspace(projectId, sessionId, message, { author = 'system' } = {}) {
765
+ const sessionRoot = getWorkspaceRoot(projectId);
766
+ if (!(await fileExists(sessionRoot))) return null;
767
+ // git race guard:用户 PUT canvas(DirectEdit 上行)+ agent Edit canvas.html
768
+ // 同时触发会撞 .git/index.lock,最坏 lock 残留导致后续 commit 全卡死。per-sessionRoot
769
+ // mutex 串行所有 git 写操作(commit / revert)。同 sid 共享 key=`git:${sessionRoot}`。
770
+ return mutex(`git:${sessionRoot}`, async () => {
771
+ await runGit(sessionRoot, ['add', '-A']);
772
+ const { stdout } = await runGit(sessionRoot, ['status', '--porcelain'], { capture: true });
773
+ if (!stdout.trim()) return null;
774
+ await runGit(sessionRoot, [
775
+ '-c', `user.email=${author}@nodesign`,
776
+ '-c', `user.name=${author}`,
777
+ 'commit', '-q', '-m', message,
778
+ ]);
779
+ const { stdout: hash } = await runGit(sessionRoot, ['rev-parse', 'HEAD'], { capture: true });
780
+ return hash.trim();
781
+ });
782
+ }
783
+
784
+ /**
785
+ * 从某个 commit 到 HEAD 之间,git 认出来的**改名**。
786
+ *
787
+ * 画布物件的 id 就是工作区相对路径,所以 agent 在画布背后 `mv` 一个文件,
788
+ * 画布上那张卡的身份就断了:坐标丢、关系线指向虚空、挂在它上面的批注成孤儿。
789
+ * 而且**清理不掉** —— board.objects 是故意稀疏的(没被摆过的产物压根没有条目),
790
+ * 所以"在 board 里但磁盘上没有"跟"agent 正在写、这一瞬读不到"没法区分,
791
+ * 死 id 只能一直攒着。
792
+ *
793
+ * 靠 git 来认这件事,是因为它已经是这个工作区的历史,而且**自带内容相似度
794
+ * 匹配** —— 一个文件被 mv 的同时改了几行,`-M` 照样认得出来,这是任何
795
+ * 自建的路径比对做不到的。前提是每轮 turn 之后真的落了 commit
796
+ * (2026-08-08 之前只有"用户直接编辑 HTML"那一条路会提交)。
797
+ *
798
+ * @returns {Promise<{ head: string|null, renames: Array<[string,string]> }>}
799
+ */
800
+ export async function gitRenamesSince(projectId, fromCommit) {
801
+ const root = getWorkspaceRoot(projectId);
802
+ if (!(await fileExists(path.join(root, '.git')))) return { head: null, renames: [] };
803
+ return mutex(`git:${root}`, async () => {
804
+ let head = null;
805
+ try {
806
+ const { stdout } = await runGit(root, ['rev-parse', 'HEAD'], { capture: true });
807
+ head = stdout.trim();
808
+ } catch { return { head: null, renames: [] }; }
809
+ if (!head || !fromCommit || fromCommit === head) return { head, renames: [] };
810
+
811
+ let out = '';
812
+ try {
813
+ // -M50% 比默认宽松些:改名的同时顺手改几行内容是常态(重命名一份 deck
814
+ // 往往连标题一起改)。太严的话这类改名认不出来,退化成"删一个加一个"。
815
+ const r = await runGit(root, [
816
+ 'diff', '--name-status', '--find-renames=50%', '-z', fromCommit, head,
817
+ ], { capture: true });
818
+ out = r.stdout;
819
+ } catch { return { head, renames: [] }; }
820
+
821
+ // -z 的格式:状态 \0 旧路径 \0 新路径 \0(改名/复制是三段,其余两段)。
822
+ // 用 -z 而不是换行分隔,是因为产物名里有中文和空格,默认输出会加引号转义。
823
+ const parts = out.split('\0');
824
+ const renames = [];
825
+ for (let i = 0; i < parts.length; i++) {
826
+ const st = parts[i];
827
+ if (!st) continue;
828
+ if (st[0] === 'R') { renames.push([parts[i + 1], parts[i + 2]]); i += 2; }
829
+ else i += 1; // 其余状态只跟一个路径
830
+ }
831
+ const files = renames.filter(([a, b]) => a && b);
832
+ return { head, renames: [...await deriveFolderRenames(root, files), ...files] };
833
+ });
834
+ }
835
+
836
+ /**
837
+ * 从文件级改名推出**目录改名**。
838
+ *
839
+ * git 只认文件:`mv 稿件 定稿` 报出来是一串
840
+ * `稿件/a.md → 定稿/a.md`、`稿件/b.md → 定稿/b.md`。而画布上的文件夹条目
841
+ * (位置、标题、收起状态)的 id 是 `稿件` —— 没有任何一条文件配对匹配得上它,
842
+ * 于是**机制二能改物件,永远改不了文件夹**。
843
+ *
844
+ * 推法:一对路径去掉最长公共后缀,剩下的头就是目录改名的候选
845
+ * (`稿件/初稿/a.md → 定稿/初稿/a.md` 去掉 `/初稿/a.md` 得 `稿件 → 定稿`,
846
+ * 天然拿到最高一层,正好是 mapId 前缀匹配要的粒度)。
847
+ *
848
+ * 然后**拿磁盘验一遍**才算数:老的确实没了、新的确实在。只有一个文件从
849
+ * A/ 挪进 B/ 也会产生候选,但那时 A/ 还在,验不过。
850
+ *
851
+ * 目录排在文件前面返回:mapId 顺着 renames 的顺序找第一个命中,先按目录前缀
852
+ * 改能一次盖住整棵子树,省得每个文件各改一遍还可能改出中间态。
853
+ */
854
+ async function deriveFolderRenames(root, filePairs) {
855
+ const cand = new Map();
856
+ for (const [from, to] of filePairs) {
857
+ const a = from.split('/');
858
+ const b = to.split('/');
859
+ let i = a.length - 1; let j = b.length - 1;
860
+ while (i >= 0 && j >= 0 && a[i] === b[j]) { i -= 1; j -= 1; }
861
+ if (i < 0 || j < 0) continue; // 一方是另一方的子路径,不是改名
862
+ const dirA = a.slice(0, i + 1).join('/');
863
+ const dirB = b.slice(0, j + 1).join('/');
864
+ if (dirA && dirB && dirA !== dirB) cand.set(dirA, dirB);
865
+ }
866
+ const out = [];
867
+ for (const [dirA, dirB] of cand) {
868
+ if (await pathExists(path.join(root, dirA))) continue; // 老的还在 = 没搬走
869
+ if (!(await pathExists(path.join(root, dirB)))) continue; // 新的不在 = 别的事
870
+ out.push([dirA, dirB]);
871
+ }
872
+ // 长的排前面:`稿件/初稿` 要先于 `稿件` 匹配,否则外层一改,内层那条就对不上了
873
+ out.sort((x, y) => y[0].length - x[0].length);
874
+ return out;
875
+ }
876
+
877
+ /**
878
+ * 任务目录自己的 git。
879
+ *
880
+ * 为什么不复用 per-session git:**它根本盖不到任务文件。** git 仓在
881
+ * `sessions/<sid>/.git`,而任务物理上在 `shared/tasks/`,会话里的 `tasks/` 只是
882
+ * 一条软链。git 不跟随软链,`git add -A` 把 `tasks` 存成一个 mode 120000 的软链
883
+ * 对象,任务文件内容从来没进过任何历史。线上实测过:随便挑个会话仓 cat-file,
884
+ * tasks 就是个 120000 blob,且仓里只有一条 init commit。
885
+ *
886
+ * 就算能盖到也不该复用:session 仓的 checkout 会把 spec.json、notes 这些**会话
887
+ * 状态**一起回退,而任务级的回退要的是「这份产物回到几步之前」,粒度不同。
888
+ *
889
+ * 懒初始化:第一次提交时才 init,没人调用就永远不会有 .git。
890
+ *
891
+ * ⚠️ 目前没有调用方(唯一的消费者随 world 形态一起拆了,2026-08-14)。留着是
892
+ * 因为它跟形态无关 —— 下一个需要"按产物回退"的形态直接用,别再造一遍。
893
+ *
894
+ * @returns {Promise<string|null>} commit hash;没有改动返回 null
895
+ */
896
+ export async function commitTaskWorkspace(taskDir, message, { author = 'agent' } = {}) {
897
+ if (!(await fileExists(taskDir))) return null;
898
+ return mutex(`git:${taskDir}`, async () => {
899
+ if (!(await fileExists(path.join(taskDir, '.git')))) {
900
+ await runGit(taskDir, ['init', '-q', '-b', 'main']);
901
+ }
902
+ await runGit(taskDir, ['add', '-A']);
903
+ const { stdout } = await runGit(taskDir, ['status', '--porcelain'], { capture: true });
904
+ if (!stdout.trim()) return null;
905
+ await runGit(taskDir, [
906
+ '-c', `user.email=${author}@nodesign`,
907
+ '-c', `user.name=${author}`,
908
+ 'commit', '-q', '-m', message,
909
+ ]);
910
+ const { stdout: hash } = await runGit(taskDir, ['rev-parse', 'HEAD'], { capture: true });
911
+ return hash.trim();
912
+ });
913
+ }
914
+
915
+ export async function listHistory(projectId, sessionId, { limit = 50 } = {}) {
916
+ // 同 commitWorkspace:git 仓是项目级一个,sessionId 不参与路径。这里直接取
917
+ // 工作区根而不是走 getSessionWorkspace —— 后者会校验 sid 形状,而项目级路由
918
+ // (2026-08-13 会话收敛)根本不带 sid,undefined 会被它一票否决。
919
+ // sid 存在与否的校验责任在路由层 guard,不在这。
920
+ const sessionRoot = getWorkspaceRoot(projectId);
921
+ if (!(await fileExists(sessionRoot))) return [];
922
+ const { stdout, code } = await runGit(
923
+ sessionRoot,
924
+ ['log', `--max-count=${limit}`, '--pretty=format:%H%x09%cI%x09%an%x09%s'],
925
+ { capture: true },
926
+ );
927
+ if (code !== 0) return [];
928
+ return stdout
929
+ .trim().split('\n').filter(Boolean)
930
+ .map((line) => {
931
+ const [hash, isoDate, gitAuthor, ...msgParts] = line.split('\t');
932
+ return { hash, date: isoDate, author: gitAuthor, message: msgParts.join('\t') };
933
+ });
934
+ }
935
+
936
+ export async function revertWorkspace(projectId, sessionId, commitHash) {
937
+ if (!/^[a-f0-9]{7,40}$/i.test(commitHash)) {
938
+ throw Object.assign(new Error(`invalid commit hash: ${commitHash}`), { code: 'INVALID_COMMIT' });
939
+ }
940
+ // 同 listHistory:项目级一个仓,sessionId 不参与路径也不在这校验
941
+ const sessionRoot = getWorkspaceRoot(projectId);
942
+ // git race guard: 同 commitWorkspace —— checkout + 后续 commit 全 wrap mutex
943
+ // 串行。注意:内层调 commitWorkspace 也会进 mutex,async-mutex-lite 对同 key 同
944
+ // 调用栈会按 prev Promise chain,**不会死锁**(mutex 拿到后释放 prev、await prev
945
+ // 已是 resolved 立即继续)—— 但为简洁还是把 checkout + commit 做成原子段,避免
946
+ // checkout 完别的 commit 抢进来覆盖待 commit 的 staged 状态。
947
+ return mutex(`git:${sessionRoot}`, async () => {
948
+ await runGit(sessionRoot, ['checkout', commitHash, '--', '.']);
949
+ await runGit(sessionRoot, ['add', '-A']);
950
+ const { stdout } = await runGit(sessionRoot, ['status', '--porcelain'], { capture: true });
951
+ if (!stdout.trim()) return null;
952
+ await runGit(sessionRoot, [
953
+ '-c', 'user.email=user@nodesign',
954
+ '-c', 'user.name=user',
955
+ 'commit', '-q', '-m', `revert to ${commitHash.slice(0, 7)}`,
956
+ ]);
957
+ const { stdout: hash } = await runGit(sessionRoot, ['rev-parse', 'HEAD'], { capture: true });
958
+ return hash.trim();
959
+ });
960
+ }
961
+
962
+ // ── fork ──
963
+
964
+ /**
965
+ * Fork 一条对话。**不再复制任何文件。**
966
+ *
967
+ * 以前 fork 要 `cp -r sessions/<src> → sessions/<new>`(连 .git 一起,语义是
968
+ * "从这里继续")。产物归项目之后这件事没有对应物了:两条分叉的对话面对的是
969
+ * 同一个工作区,复制一份产物出来反而会造出两套互不相干的文件。
970
+ *
971
+ * 所以 fork 现在只发生在 SDK 那一侧(复制 jsonl 到新 sid),这里只把新会话的
972
+ * 私档目录备好。
973
+ */
974
+ export async function forkSessionWorkspace(projectId, srcSessionId, newSessionId) {
975
+ validateSessionId(srcSessionId);
976
+ validateSessionId(newSessionId);
977
+ const root = getWorkspaceRoot(projectId);
978
+ if (!(await fileExists(root))) {
979
+ throw Object.assign(new Error(`fork source project not found: ${projectId}`), { code: 'SRC_NOT_FOUND' });
980
+ }
981
+ await fs.mkdir(getSessionMetaDir(projectId, newSessionId), { recursive: true });
982
+ return root;
983
+ }
984
+
985
+ // ── helpers ──
986
+
987
+ async function fileExists(p) {
988
+ try {
989
+ await fs.access(p);
990
+ return true;
991
+ } catch {
992
+ return false;
993
+ }
994
+ }
995
+
996
+ async function pathExists(p) {
997
+ // 区分 fileExists 用于"包括软链 dangling"的检查(lstat 不 follow)
998
+ try {
999
+ await fs.lstat(p);
1000
+ return true;
1001
+ } catch {
1002
+ return false;
1003
+ }
1004
+ }
1005
+
1006
+ function runGit(cwd, args, { capture = false } = {}) {
1007
+ return new Promise((resolve, reject) => {
1008
+ const child = spawn('git', args, {
1009
+ cwd,
1010
+ stdio: capture ? ['ignore', 'pipe', 'pipe'] : ['ignore', 'inherit', 'inherit'],
1011
+ });
1012
+ let stdout = '';
1013
+ let stderr = '';
1014
+ if (capture) {
1015
+ child.stdout.on('data', (d) => { stdout += d; });
1016
+ child.stderr.on('data', (d) => { stderr += d; });
1017
+ }
1018
+ child.on('error', reject);
1019
+ child.on('close', (code) => {
1020
+ if (capture) resolve({ code, stdout, stderr });
1021
+ else if (code === 0) resolve({ code });
1022
+ else reject(new Error(`git ${args.join(' ')} failed (code=${code})`));
1023
+ });
1024
+ });
1025
+ }