@aiworker/web 1.22.2 → 1.23.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 (217) hide show
  1. package/bin/cli.js +7 -0
  2. package/dist/assets/{CodeMirrorCommentWidgets-CSICpWQD.js → CodeMirrorCommentWidgets-teB3HmBT.js} +1 -1
  3. package/dist/assets/DiffView-DC9RX3MF.js +4 -0
  4. package/dist/assets/{DirtyBranchSwitchDialog-C22HdWj4.js → DirtyBranchSwitchDialog-DwPkzXsS.js} +1 -1
  5. package/dist/assets/FilesView-BFZkuu4O.js +13 -0
  6. package/dist/assets/{GitView-VQQkYuUw.js → GitView-B_x52MHY.js} +4 -4
  7. package/dist/assets/{JsonTreeView-6lrhwD3q.js → JsonTreeView-cmAzUTiw.js} +1 -1
  8. package/dist/assets/{LinearIssuesView-Ba6Q9569.js → LinearIssuesView-Dehh5afm.js} +1 -1
  9. package/dist/assets/MarkdownImageGallery-DgNOqqhE.js +1 -0
  10. package/dist/assets/MarkdownRendererImpl-DusmMZ_p.js +12 -0
  11. package/dist/assets/OnboardingScreen-B8fm1H4m.js +1 -0
  12. package/dist/assets/{PatchDiff-BzcZPlOq.js → PatchDiff-BVr7gJU0.js} +1 -1
  13. package/dist/assets/PierreDiffViewer-DzLt-nel.js +161 -0
  14. package/dist/assets/PlanView-BmQUYT_z.js +5 -0
  15. package/dist/assets/SettingsView-ChWmAcZT.js +28 -0
  16. package/dist/assets/{SettingsWindow-DDQYaKWQ.js → SettingsWindow-Dy3M6ZB9.js} +1 -1
  17. package/dist/assets/SymbolsNerdFontMono-Regular-aK5vsLov.woff2 +0 -0
  18. package/dist/assets/{ToolOutputDialog-iYF8Y2bz.js → ToolOutputDialog-cTl_eLIH.js} +5 -5
  19. package/dist/assets/{ToolPartDiffPreview-CdjbOVMM.js → ToolPartDiffPreview-C_3iYNJQ.js} +1 -1
  20. package/dist/assets/{VirtualizedFileDiff-fFFm2QgH.js → VirtualizedFileDiff-CAWthkoy.js} +4 -4
  21. package/dist/assets/WalkthroughView-C7AHOpCA.js +4 -0
  22. package/dist/assets/{computeVirtualFileMetrics-CzgLum_K.js → computeVirtualFileMetrics-BkiDSlTW.js} +2 -2
  23. package/dist/assets/contextPanelEmbeddedChat-BdA6bdky.js +1 -0
  24. package/dist/assets/de-DJsQr-p7.js +10 -0
  25. package/dist/assets/es-BuYMlA9g.js +16 -0
  26. package/dist/assets/fr-ePDay33C.js +14 -0
  27. package/dist/assets/ghostty-vt-DdA0Zryv.wasm +0 -0
  28. package/dist/assets/index-BD8ILbxF.js +40 -0
  29. package/dist/assets/{index-Bl3LSbjn.js → index-n6O3hklM.js} +1 -1
  30. package/dist/assets/index-zhgg-SDz.css +1 -0
  31. package/dist/assets/{isDiffPlainText-Dx-dB_8l.js → isDiffPlainText-GMdPz-71.js} +1 -1
  32. package/dist/assets/{isFilePlainText-CWFPwmnU.js → isFilePlainText-BVuH1zH9.js} +1 -1
  33. package/dist/assets/ja-BzR9xOY_.js +16 -0
  34. package/dist/assets/ko-BEjHAm14.js +16 -0
  35. package/dist/assets/main-BydQ1tr_.js +930 -0
  36. package/dist/assets/{main-DnBAoktx.js → main-D3ZDQglc.js} +2 -2
  37. package/dist/assets/markdownCore-Cb2MdZnV.js +5 -0
  38. package/dist/assets/{miniChat-BKyvLseN.js → miniChat-d01k801U.js} +2 -2
  39. package/dist/assets/{mobile-DKjeFgMN.js → mobile-D7VV-Ufz.js} +2 -2
  40. package/dist/assets/patchFileDiff-CdztVMz0.js +13 -0
  41. package/dist/assets/pl-rQmpxNIF.js +16 -0
  42. package/dist/assets/pt-BR-CorbsSW-.js +16 -0
  43. package/dist/assets/renderElectronMiniChatApp-BGG9UIlh.js +2 -0
  44. package/dist/assets/renderMobileApp-BRP3J1iv.js +25 -0
  45. package/dist/assets/runtimeEndpointReset-BhT_-c52.js +41 -0
  46. package/dist/assets/scheduledTasksApi-BEuUF_cj.js +1 -0
  47. package/dist/assets/{shikiHighlight-TEQCmzW8.js → shikiHighlight-BzXUjlby.js} +1 -1
  48. package/dist/assets/surface-COKb77Ua.js +1 -0
  49. package/dist/assets/tr-CoAMXPtI.js +7 -0
  50. package/dist/assets/uk-YlAX0rRU.js +16 -0
  51. package/dist/assets/{useAppFontEffects-D7sNpuL2.js → useAppFontEffects-Cwyhjfl5.js} +141 -140
  52. package/dist/assets/useGitComparison-BCZVefdE.js +1 -0
  53. package/dist/assets/useInlineCommentController-DDkpBEU8.js +2 -0
  54. package/dist/assets/useRootScrollLock-CwjoWuC4.js +1 -0
  55. package/dist/assets/{vendor-base-ui-BeMD8X_w.js → vendor-base-ui-BY_5qla_.js} +1 -1
  56. package/dist/assets/{vendor-codemirror-language-data-Dp3KMRQG.js → vendor-codemirror-language-data-Cb1YnICj.js} +2 -2
  57. package/dist/assets/vendor-legendapp-list-BuBPBC3R.js +1 -0
  58. package/dist/assets/vendor-zod-B6551dwG.js +39 -0
  59. package/dist/assets/{web-components-_mNEMjFY.js → web-components-LV3vjTxy.js} +1 -1
  60. package/dist/assets/zh-CN-DDYg0Jl7.js +16 -0
  61. package/dist/assets/zh-TW-CQKaCayr.js +16 -0
  62. package/dist/index.html +5 -43
  63. package/dist/mini-chat.html +4 -4
  64. package/dist/mobile.html +4 -4
  65. package/package.json +2 -3
  66. package/server/index.d.ts +15 -0
  67. package/server/index.js +48 -22
  68. package/server/lib/agent-memory/runtime.js +7 -2
  69. package/server/lib/agent-memory/runtime.test.js +22 -0
  70. package/server/lib/data-dir-migration.js +40 -0
  71. package/server/lib/data-dir-migration.test.js +59 -0
  72. package/server/lib/event-stream/DOCUMENTATION.md +3 -0
  73. package/server/lib/event-stream/global-hub.js +29 -4
  74. package/server/lib/event-stream/global-hub.test.js +22 -0
  75. package/server/lib/event-stream/global-ws-bridge.js +15 -18
  76. package/server/lib/event-stream/protocol.js +21 -7
  77. package/server/lib/event-stream/runtime.js +9 -6
  78. package/server/lib/event-stream/runtime.test.js +72 -12
  79. package/server/lib/fs/DOCUMENTATION.md +3 -0
  80. package/server/lib/fs/routes.js +31 -0
  81. package/server/lib/fs/routes.test.js +110 -1
  82. package/server/lib/git/DOCUMENTATION.md +39 -6
  83. package/server/lib/git/routes.js +45 -12
  84. package/server/lib/git/routes.test.js +78 -0
  85. package/server/lib/git/service.js +343 -202
  86. package/server/lib/git/service.test.js +496 -25
  87. package/server/lib/github/DOCUMENTATION.md +5 -0
  88. package/server/lib/github/routes.js +2 -2
  89. package/server/lib/inherited-env.js +16 -9
  90. package/server/lib/inherited-env.test.js +8 -9
  91. package/server/lib/message-queue/DOCUMENTATION.md +11 -1
  92. package/server/lib/message-queue/runtime.js +13 -0
  93. package/server/lib/message-queue/runtime.test.js +31 -1
  94. package/server/lib/network-defaults.js +23 -0
  95. package/server/lib/network-defaults.test.js +52 -0
  96. package/server/lib/notifications/DOCUMENTATION.md +1 -1
  97. package/server/lib/notifications/emitter-runtime.js +4 -3
  98. package/server/lib/opencode/DOCUMENTATION.md +35 -5
  99. package/server/lib/opencode/bootstrap-runtime.js +2 -0
  100. package/server/lib/opencode/env-runtime.js +5 -1
  101. package/server/lib/opencode/env-runtime.test.js +72 -0
  102. package/server/lib/opencode/feature-routes-runtime.js +17 -1
  103. package/server/lib/opencode/lifecycle.js +4 -3
  104. package/server/lib/opencode/lifecycle.test.js +29 -0
  105. package/server/lib/opencode/managed-plugin-config.js +1 -1
  106. package/server/lib/opencode/openchamber-routes.js +64 -4
  107. package/server/lib/opencode/openchamber-routes.test.js +128 -1
  108. package/server/lib/opencode/proxy.js +33 -0
  109. package/server/lib/opencode/routes.js +5 -3
  110. package/server/lib/opencode/settings-files.js +214 -0
  111. package/server/lib/opencode/settings-helpers.js +88 -41
  112. package/server/lib/opencode/settings-helpers.test.js +176 -1
  113. package/server/lib/opencode/settings-registry.json +750 -0
  114. package/server/lib/opencode/settings-runtime.js +175 -27
  115. package/server/lib/opencode/settings-runtime.test.js +321 -7
  116. package/server/lib/project-context/DOCUMENTATION.md +38 -4
  117. package/server/lib/project-context/routes.http.test.js +18 -0
  118. package/server/lib/project-context/routes.js +15 -0
  119. package/server/lib/project-context/runtime.js +256 -25
  120. package/server/lib/project-context/runtime.test.js +160 -3
  121. package/server/lib/projects/DOCUMENTATION.md +77 -0
  122. package/server/lib/projects/project-config.js +179 -17
  123. package/server/lib/projects/project-config.test.js +97 -0
  124. package/server/lib/projects/project-id.js +43 -0
  125. package/server/lib/projects/project-setup.js +409 -0
  126. package/server/lib/projects/project-setup.test.js +557 -0
  127. package/server/lib/projects/routes.js +59 -0
  128. package/server/lib/quota/DOCUMENTATION.md +31 -1
  129. package/server/lib/quota/providers/cline-pass.js +125 -0
  130. package/server/lib/quota/providers/cline-pass.test.js +103 -0
  131. package/server/lib/quota/providers/index.js +7 -0
  132. package/server/lib/quota/providers/neuralwatt.js +3 -6
  133. package/server/lib/quota/providers/neuralwatt.test.js +7 -7
  134. package/server/lib/quota/providers/ollama-cloud.js +31 -0
  135. package/server/lib/quota/providers/ollama-cloud.test.js +33 -1
  136. package/server/lib/quota/providers/opencode-go.js +1 -1
  137. package/server/lib/quota/providers/opencode-go.test.js +26 -1
  138. package/server/lib/quota/providers/openrouter.js +119 -24
  139. package/server/lib/quota/providers/openrouter.test.js +330 -0
  140. package/server/lib/relay/DOCUMENTATION.md +75 -0
  141. package/server/lib/relay/cross-compat.test.js +26 -0
  142. package/server/lib/relay/downstream-scheduler.js +154 -0
  143. package/server/lib/relay/downstream-scheduler.test.js +162 -0
  144. package/server/lib/relay/e2ee.js +10 -8
  145. package/server/lib/relay/flow-control.test.js +177 -0
  146. package/server/lib/relay/host-client.js +44 -8
  147. package/server/lib/relay/host-client.test.js +14 -0
  148. package/server/lib/relay/tunnel-codec.js +9 -0
  149. package/server/lib/relay/tunnel-host.js +66 -17
  150. package/server/lib/scheduled-tasks/DOCUMENTATION.md +9 -1
  151. package/server/lib/scheduled-tasks/issue-2710-double-execution.test.js +28 -0
  152. package/server/lib/scheduled-tasks/runtime.js +12 -2
  153. package/server/lib/scheduled-tasks/runtime.test.js +53 -0
  154. package/server/lib/session-assist/DOCUMENTATION.md +131 -66
  155. package/server/lib/session-assist/context.js +127 -0
  156. package/server/lib/session-assist/context.test.js +132 -0
  157. package/server/lib/session-assist/prompt.js +54 -0
  158. package/server/lib/session-assist/runtime.js +117 -267
  159. package/server/lib/session-assist/runtime.test.js +203 -0
  160. package/server/lib/session-goal/runtime.js +4 -9
  161. package/server/lib/small-model/DOCUMENTATION.md +3 -2
  162. package/server/lib/small-model/call.js +9 -4
  163. package/server/lib/small-model/call.test.js +29 -0
  164. package/server/lib/small-model/index.js +5 -10
  165. package/server/lib/small-model/runtime-providers.js +15 -7
  166. package/server/lib/small-model/runtime-providers.test.js +7 -1
  167. package/server/lib/terminal/DOCUMENTATION.md +4 -4
  168. package/server/lib/terminal/runtime.js +11 -6
  169. package/server/lib/terminal/runtime.test.js +20 -13
  170. package/server/lib/walkthrough/DOCUMENTATION.md +49 -20
  171. package/server/lib/walkthrough/model-settings.js +8 -12
  172. package/server/lib/walkthrough/pr-source.test.js +21 -0
  173. package/server/lib/walkthrough/prompt.js +3 -1
  174. package/server/lib/walkthrough/pull-request.js +7 -4
  175. package/server/lib/walkthrough/pull-request.test.js +16 -0
  176. package/server/lib/walkthrough/routes.js +19 -0
  177. package/server/lib/walkthrough/routes.test.js +26 -0
  178. package/server/lib/walkthrough/sources.js +36 -4
  179. package/server/opencode-worktree-readiness.test.js +130 -0
  180. package/dist/assets/DiffView-hDBKesA_.js +0 -4
  181. package/dist/assets/FilesView-CdWzeOp9.js +0 -17
  182. package/dist/assets/MarkdownImageGallery-Czv_mhvS.js +0 -1
  183. package/dist/assets/MarkdownRendererImpl-BrMcswiy.js +0 -12
  184. package/dist/assets/OnboardingScreen-ChxQoAkx.js +0 -1
  185. package/dist/assets/PierreDiffViewer-BchBtZlp.js +0 -157
  186. package/dist/assets/PlanView-DyDt_nZ_.js +0 -5
  187. package/dist/assets/SettingsView-BKf5xBsP.js +0 -28
  188. package/dist/assets/WalkthroughView-DOeCsMju.js +0 -4
  189. package/dist/assets/contextPanelEmbeddedChat-C0ZLAepL.js +0 -1
  190. package/dist/assets/de-B5JaA3SA.js +0 -10
  191. package/dist/assets/es-EPIH88ii.js +0 -16
  192. package/dist/assets/fr-CAMfxUdZ.js +0 -14
  193. package/dist/assets/index-DF5kMP-i.js +0 -39
  194. package/dist/assets/index-Dl3xbAB_.css +0 -1
  195. package/dist/assets/ja-CifOE1VN.js +0 -16
  196. package/dist/assets/ko-BBDyAyYQ.js +0 -16
  197. package/dist/assets/main-MhSaOZoB.js +0 -930
  198. package/dist/assets/markdownCore-BrBwOOMn.js +0 -3
  199. package/dist/assets/patchFileDiff-o8ms1me8.js +0 -10
  200. package/dist/assets/pl-DgEHYfFM.js +0 -16
  201. package/dist/assets/pt-BR-DuWHojUw.js +0 -16
  202. package/dist/assets/renderElectronMiniChatApp-CDgBwwCS.js +0 -2
  203. package/dist/assets/renderMobileApp-DJIKIK1r.js +0 -25
  204. package/dist/assets/runtimeEndpointReset-CYJ9O_xX.js +0 -17
  205. package/dist/assets/scheduledTasksApi-D8yamh1b.js +0 -1
  206. package/dist/assets/tr-D5m2yIWL.js +0 -7
  207. package/dist/assets/uk-D_q60oog.js +0 -16
  208. package/dist/assets/useInlineCommentController-Brz_cLcQ.js +0 -2
  209. package/dist/assets/useRootScrollLock-lXEnWlaU.js +0 -1
  210. package/dist/assets/vendor-ghostty-web-CrxinGGH.js +0 -13
  211. package/dist/assets/vendor-legendapp-list-gg7YLRW7.js +0 -1
  212. package/dist/assets/vendor-zod-CK21ihK-.js +0 -39
  213. package/dist/assets/zh-CN-D8QvsVeg.js +0 -16
  214. package/dist/assets/zh-TW-B9t7rQQO.js +0 -16
  215. package/server/lib/mcp-reconnect/DOCUMENTATION.md +0 -55
  216. package/server/lib/mcp-reconnect/runtime.js +0 -122
  217. package/server/lib/mcp-reconnect/runtime.test.js +0 -140
@@ -12,6 +12,8 @@
12
12
  * directory never invalidates a reference.
13
13
  */
14
14
 
15
+ import { projectConfigFileStemOf } from '../projects/project-id.js';
16
+
15
17
  const PROJECT_CONTEXT_VERSION = 2;
16
18
  const PROJECT_NOTE_BODY_MAX_LENGTH = 3000;
17
19
  const PROJECT_NOTE_MAX_ITEMS = 200;
@@ -164,15 +166,22 @@ const sanitizePlanLinks = (value, now) => {
164
166
  const id = asNonEmptyString(entry.id);
165
167
  const file = asNonEmptyString(entry.file);
166
168
  if (!id || !file || !PLAN_FILE_PATTERN.test(file)) continue;
167
- if (seenIds.has(id) || seenFiles.has(file)) continue;
169
+ // A personal file and a shared file may carry the same name: they live in different folders.
170
+ const shared = entry.shared === true;
171
+ const fileKey = `${shared ? 'shared' : 'personal'}/${file}`;
172
+ if (seenIds.has(id) || seenFiles.has(fileKey)) continue;
168
173
  seenIds.add(id);
169
- seenFiles.add(file);
174
+ seenFiles.add(fileKey);
170
175
  result.push({
171
176
  id,
172
177
  file,
173
178
  title: sanitizePlanTitle(entry.title) || 'Plan',
174
179
  createdAt: Number.isFinite(entry.createdAt) && entry.createdAt >= 0 ? entry.createdAt : now,
175
180
  pinned: entry.pinned === true,
181
+ // The user's own plan that was moved into the team's folder: it keeps
182
+ // its id (session attachments still point at it) and this flag says
183
+ // which folder holds the file.
184
+ shared,
176
185
  });
177
186
  }
178
187
  return result.sort((a, b) => b.createdAt - a.createdAt);
@@ -185,8 +194,15 @@ const createEmptyContext = () => ({
185
194
  plans: [],
186
195
  });
187
196
 
197
+ const SHARED_PLAN_ID_PREFIX = 'shared:';
198
+
188
199
  export const createProjectContextRuntime = (deps) => {
189
- const { fsPromises, path, projectsDirPath, createId } = deps;
200
+ const { fsPromises, path, projectsDirPath, createId, resolveSharedPlansDir } = deps;
201
+ // The team's shared plans folder for a project (absolute path) or null; the
202
+ // project config runtime owns that answer (`plansDir` in the shared file).
203
+ const sharedPlansDirFor = typeof resolveSharedPlansDir === 'function'
204
+ ? resolveSharedPlansDir
205
+ : async () => null;
190
206
 
191
207
  const idFactory = typeof createId === 'function'
192
208
  ? createId
@@ -207,10 +223,14 @@ export const createProjectContextRuntime = (deps) => {
207
223
  return value;
208
224
  };
209
225
 
210
- const storageDirFor = (projectId) => path.join(projectsDirPath, sanitizeProjectId(projectId));
226
+ // The folder and the client-owned config file share one bounded stem
227
+ // (`projectConfigFileStemOf`): an id too long for a file name would
228
+ // otherwise fail every read with ENAMETOOLONG instead of reading as empty.
229
+ const storageStemFor = (projectId) => projectConfigFileStemOf(sanitizeProjectId(projectId));
230
+ const storageDirFor = (projectId) => path.join(projectsDirPath, storageStemFor(projectId));
211
231
  const contextPathFor = (projectId) => path.join(storageDirFor(projectId), 'context.json');
212
232
  const plansDirFor = (projectId) => path.join(storageDirFor(projectId), 'plans');
213
- const legacyConfigPathFor = (projectId) => path.join(projectsDirPath, `${sanitizeProjectId(projectId)}.json`);
233
+ const legacyConfigPathFor = (projectId) => path.join(projectsDirPath, `${storageStemFor(projectId)}.json`);
214
234
 
215
235
  const readJson = async (filePath) => {
216
236
  let raw;
@@ -356,7 +376,7 @@ export const createProjectContextRuntime = (deps) => {
356
376
  * of identical content, so concurrent migrations converge instead of
357
377
  * interleaving.
358
378
  */
359
- const readContext = async (projectId) => {
379
+ const readStoredContext = async (projectId) => {
360
380
  const now = Date.now();
361
381
  const stored = await readJson(contextPathFor(projectId));
362
382
 
@@ -385,6 +405,67 @@ export const createProjectContextRuntime = (deps) => {
385
405
  };
386
406
  };
387
407
 
408
+ /** The file name behind a shared plan id, or null when the id is not one. */
409
+ const sharedPlanFileOf = (planId) => {
410
+ if (typeof planId !== 'string' || !planId.startsWith(SHARED_PLAN_ID_PREFIX)) return null;
411
+ const file = planId.slice(SHARED_PLAN_ID_PREFIX.length);
412
+ return PLAN_FILE_PATTERN.test(file) ? file : null;
413
+ };
414
+
415
+ /**
416
+ * Plans in the team's shared folder: every `.md` file there, newest first,
417
+ * addressed by `shared:<file>`. Plans written by other tools have no
418
+ * manifest entry, so the title comes from the file each time.
419
+ */
420
+ const listSharedPlans = async (projectId, claimedFiles = new Set()) => {
421
+ const dir = await sharedPlansDirFor(projectId);
422
+ if (!dir) return { dir: null, plans: [] };
423
+ let entries;
424
+ try {
425
+ entries = await fsPromises.readdir(dir, { withFileTypes: true });
426
+ } catch (error) {
427
+ if (error && error.code === 'ENOENT') return { dir, plans: [] };
428
+ throw error;
429
+ }
430
+ const plans = [];
431
+ for (const entry of entries) {
432
+ if (!entry.isFile() || !PLAN_FILE_PATTERN.test(entry.name)) continue;
433
+ // Listed under its own id by the manifest entry that moved it there.
434
+ if (claimedFiles.has(entry.name)) continue;
435
+ const filePath = path.join(dir, entry.name);
436
+ const [raw, stat] = await Promise.all([fsPromises.readFile(filePath, 'utf8'), fsPromises.stat(filePath)]);
437
+ plans.push({
438
+ id: `${SHARED_PLAN_ID_PREFIX}${entry.name}`,
439
+ file: entry.name,
440
+ title: parsePlanMarkdown(raw).title,
441
+ createdAt: Math.round(stat.mtimeMs),
442
+ pinned: false,
443
+ source: 'shared',
444
+ });
445
+ }
446
+ plans.sort((left, right) => right.createdAt - left.createdAt);
447
+ return { dir, plans };
448
+ };
449
+
450
+ /** What clients see: the stored context plus the team's shared plans, each marked with its source. */
451
+ const readContext = async (projectId) => {
452
+ const stored = await readStoredContext(projectId);
453
+ const claimed = new Set(stored.plans.filter((plan) => plan.shared).map((plan) => plan.file));
454
+ const shared = await listSharedPlans(projectId, claimed);
455
+ const own = stored.plans
456
+ // A moved plan is only reachable while the project still has a shared folder.
457
+ .filter((plan) => !plan.shared || shared.dir)
458
+ .map(({ shared: isShared, ...plan }) => ({ ...plan, source: isShared ? 'shared' : 'personal' }));
459
+ return {
460
+ ...stored,
461
+ plans: [...own, ...shared.plans],
462
+ sharedPlansDir: shared.dir,
463
+ };
464
+ };
465
+
466
+ /** The folder a manifest plan's file lives in; null for a moved plan when the project lost its shared folder. */
467
+ const folderOfLink = async (projectId, link) => (link.shared ? sharedPlansDirFor(projectId) : plansDirFor(projectId));
468
+
388
469
  const writeContext = async (projectId, context) => {
389
470
  await writeJsonAtomic(contextPathFor(projectId), {
390
471
  version: PROJECT_CONTEXT_VERSION,
@@ -397,7 +478,7 @@ export const createProjectContextRuntime = (deps) => {
397
478
  const saveTodos = async (projectId, todos) => {
398
479
  return withWriteLock(projectId, async () => {
399
480
  const now = Date.now();
400
- const current = await readContext(projectId);
481
+ const current = await readStoredContext(projectId);
401
482
  const next = { ...current, todos: sanitizeTodos(todos, now) };
402
483
  await writeContext(projectId, next);
403
484
  return next;
@@ -420,7 +501,7 @@ export const createProjectContextRuntime = (deps) => {
420
501
 
421
502
  return withWriteLock(projectId, async () => {
422
503
  const now = Date.now();
423
- const current = await readContext(projectId);
504
+ const current = await readStoredContext(projectId);
424
505
  if (current.notes.length >= PROJECT_NOTE_MAX_ITEMS) {
425
506
  throw new Error(`A project can hold at most ${PROJECT_NOTE_MAX_ITEMS} notes`);
426
507
  }
@@ -462,7 +543,7 @@ export const createProjectContextRuntime = (deps) => {
462
543
 
463
544
  return withWriteLock(projectId, async () => {
464
545
  const now = Date.now();
465
- const current = await readContext(projectId);
546
+ const current = await readStoredContext(projectId);
466
547
  const existing = current.notes.find((note) => note.id === id);
467
548
  if (!existing) {
468
549
  return null;
@@ -486,7 +567,7 @@ export const createProjectContextRuntime = (deps) => {
486
567
  }
487
568
 
488
569
  return withWriteLock(projectId, async () => {
489
- const current = await readContext(projectId);
570
+ const current = await readStoredContext(projectId);
490
571
  if (!current.notes.some((note) => note.id === id)) {
491
572
  return { deleted: false, context: current };
492
573
  }
@@ -501,22 +582,38 @@ export const createProjectContextRuntime = (deps) => {
501
582
  if (!id) {
502
583
  throw new Error('planId is required');
503
584
  }
504
- const context = await readContext(projectId);
585
+ const sharedFile = sharedPlanFileOf(id);
586
+ if (sharedFile) {
587
+ const dir = await sharedPlansDirFor(projectId);
588
+ if (!dir) return null;
589
+ let raw;
590
+ try {
591
+ raw = await fsPromises.readFile(path.join(dir, sharedFile), 'utf8');
592
+ } catch (error) {
593
+ if (error && error.code === 'ENOENT') return null;
594
+ throw error;
595
+ }
596
+ const parsed = parsePlanMarkdown(raw);
597
+ return { id, file: sharedFile, createdAt: 0, title: parsed.title, body: parsed.body, raw, source: 'shared' };
598
+ }
599
+ const context = await readStoredContext(projectId);
505
600
  const link = context.plans.find((entry) => entry.id === id);
506
601
  if (!link) {
507
602
  return null;
508
603
  }
509
604
 
605
+ const folder = await folderOfLink(projectId, link);
606
+ if (!folder) return null;
510
607
  let raw;
511
608
  try {
512
- raw = await fsPromises.readFile(path.join(plansDirFor(projectId), link.file), 'utf8');
609
+ raw = await fsPromises.readFile(path.join(folder, link.file), 'utf8');
513
610
  } catch (error) {
514
611
  if (error && error.code === 'ENOENT') return null;
515
612
  throw error;
516
613
  }
517
614
 
518
615
  const parsed = parsePlanMarkdown(raw);
519
- return { id: link.id, file: link.file, createdAt: link.createdAt, title: parsed.title, body: parsed.body, raw };
616
+ return { id: link.id, file: link.file, createdAt: link.createdAt, title: parsed.title, body: parsed.body, raw, source: link.shared ? 'shared' : 'personal' };
520
617
  };
521
618
 
522
619
  /**
@@ -541,14 +638,37 @@ export const createProjectContextRuntime = (deps) => {
541
638
  }
542
639
  const raw = clampLength(value.raw, PROJECT_PLAN_BODY_MAX_LENGTH);
543
640
 
641
+ const sharedFile = sharedPlanFileOf(id);
642
+ if (sharedFile) {
643
+ // A shared plan is the file itself: written verbatim, no manifest.
644
+ return withWriteLock(projectId, async () => {
645
+ const dir = await sharedPlansDirFor(projectId);
646
+ if (!dir) return null;
647
+ const filePath = path.join(dir, sharedFile);
648
+ try {
649
+ await fsPromises.access(filePath);
650
+ } catch (error) {
651
+ if (error && error.code === 'ENOENT') return null;
652
+ throw error;
653
+ }
654
+ await fsPromises.writeFile(filePath, raw, 'utf8');
655
+ const parsed = parsePlanMarkdown(raw);
656
+ const context = await readContext(projectId);
657
+ const plan = context.plans.find((entry) => entry.id === id) ?? { id, file: sharedFile, title: parsed.title, createdAt: Date.now(), pinned: false, source: 'shared' };
658
+ return { plan, context, title: parsed.title, body: parsed.body, raw };
659
+ });
660
+ }
661
+
544
662
  return withWriteLock(projectId, async () => {
545
- const current = await readContext(projectId);
663
+ const current = await readStoredContext(projectId);
546
664
  const link = current.plans.find((entry) => entry.id === id);
547
665
  if (!link) {
548
666
  return null;
549
667
  }
550
668
 
551
- const filePath = path.join(plansDirFor(projectId), link.file);
669
+ const folder = await folderOfLink(projectId, link);
670
+ if (!folder) return null;
671
+ const filePath = path.join(folder, link.file);
552
672
  // Refuse to recreate a file that was deleted underneath us: the link is
553
673
  // already dead, and writing here would resurrect it with editor content
554
674
  // the user believed was discarded.
@@ -569,7 +689,8 @@ export const createProjectContextRuntime = (deps) => {
569
689
  };
570
690
  await writeContext(projectId, next);
571
691
 
572
- return { plan: nextLink, context: next, title: parsed.title, body: parsed.body, raw };
692
+ const { shared: isShared, ...publicLink } = nextLink;
693
+ return { plan: { ...publicLink, source: isShared ? 'shared' : 'personal' }, context: await readContext(projectId), title: parsed.title, body: parsed.body, raw };
573
694
  });
574
695
  };
575
696
 
@@ -586,7 +707,7 @@ export const createProjectContextRuntime = (deps) => {
586
707
  const body = clampLength(typeof value?.body === 'string' ? value.body : '', PROJECT_PLAN_BODY_MAX_LENGTH);
587
708
 
588
709
  return withWriteLock(projectId, async () => {
589
- const current = await readContext(projectId);
710
+ const current = await readStoredContext(projectId);
590
711
  const createdAt = Date.now();
591
712
  const plansDir = plansDirFor(projectId);
592
713
  await fsPromises.mkdir(plansDir, { recursive: true });
@@ -594,7 +715,7 @@ export const createProjectContextRuntime = (deps) => {
594
715
  const baseName = `${createdAt}-${slugifyPlanTitle(title)}`;
595
716
  let file = `${baseName}.md`;
596
717
  let attempt = 1;
597
- while (current.plans.some((entry) => entry.file === file)) {
718
+ while (current.plans.some((entry) => !entry.shared && entry.file === file)) {
598
719
  file = `${baseName}-${attempt}.md`;
599
720
  attempt += 1;
600
721
  }
@@ -604,7 +725,7 @@ export const createProjectContextRuntime = (deps) => {
604
725
  const link = { id: idFactory(), file, title, createdAt, pinned: false };
605
726
  const next = { ...current, plans: [link, ...current.plans] };
606
727
  await writeContext(projectId, next);
607
- return { plan: link, context: next };
728
+ return { plan: { ...link, source: 'personal' }, context: await readContext(projectId) };
608
729
  });
609
730
  };
610
731
 
@@ -623,7 +744,7 @@ export const createProjectContextRuntime = (deps) => {
623
744
  }
624
745
 
625
746
  return withWriteLock(projectId, async () => {
626
- const current = await readContext(projectId);
747
+ const current = await readStoredContext(projectId);
627
748
  const existing = current.plans.find((entry) => entry.id === id);
628
749
  if (!existing) {
629
750
  return null;
@@ -631,7 +752,8 @@ export const createProjectContextRuntime = (deps) => {
631
752
  const plan = { ...existing, pinned: pinned === true };
632
753
  const next = { ...current, plans: current.plans.map((entry) => (entry.id === id ? plan : entry)) };
633
754
  await writeContext(projectId, next);
634
- return { plan, context: next };
755
+ const { shared: isShared, ...publicPlan } = plan;
756
+ return { plan: { ...publicPlan, source: isShared ? 'shared' : 'personal' }, context: await readContext(projectId) };
635
757
  });
636
758
  };
637
759
 
@@ -641,17 +763,124 @@ export const createProjectContextRuntime = (deps) => {
641
763
  throw new Error('planId is required');
642
764
  }
643
765
 
766
+ const sharedFile = sharedPlanFileOf(id);
767
+ if (sharedFile) {
768
+ return withWriteLock(projectId, async () => {
769
+ const dir = await sharedPlansDirFor(projectId);
770
+ const filePath = dir ? path.join(dir, sharedFile) : null;
771
+ const exists = filePath ? await fsPromises.access(filePath).then(() => true, () => false) : false;
772
+ if (!exists) return { deleted: false, context: await readContext(projectId) };
773
+ await fsPromises.rm(filePath, { force: true });
774
+ return { deleted: true, context: await readContext(projectId) };
775
+ });
776
+ }
777
+
644
778
  return withWriteLock(projectId, async () => {
645
- const current = await readContext(projectId);
779
+ const current = await readStoredContext(projectId);
646
780
  const link = current.plans.find((entry) => entry.id === id);
647
781
  if (!link) {
648
- return { deleted: false, context: current };
782
+ return { deleted: false, context: await readContext(projectId) };
649
783
  }
650
784
 
651
785
  const next = { ...current, plans: current.plans.filter((entry) => entry.id !== id) };
652
786
  await writeContext(projectId, next);
653
- await fsPromises.rm(path.join(plansDirFor(projectId), link.file), { force: true });
654
- return { deleted: true, context: next };
787
+ const folder = await folderOfLink(projectId, link);
788
+ if (folder) await fsPromises.rm(path.join(folder, link.file), { force: true });
789
+ return { deleted: true, context: await readContext(projectId) };
790
+ });
791
+ };
792
+
793
+ /** A free file name in `dir`, keeping the wanted base name unless it is taken. */
794
+ const freeFileNameIn = async (dir, wanted, taken = new Set()) => {
795
+ const base = wanted.replace(/\.md$/, '');
796
+ let file = wanted;
797
+ let attempt = 1;
798
+ while (taken.has(file) || await fsPromises.access(path.join(dir, file)).then(() => true, () => false)) {
799
+ file = `${base}-${attempt}.md`;
800
+ attempt += 1;
801
+ }
802
+ return file;
803
+ };
804
+
805
+ const moveFile = async (from, to) => {
806
+ try {
807
+ await fsPromises.rename(from, to);
808
+ } catch (error) {
809
+ if (!error || error.code !== 'EXDEV') throw error;
810
+ await fsPromises.copyFile(from, to);
811
+ await fsPromises.rm(from, { force: true });
812
+ }
813
+ };
814
+
815
+ /**
816
+ * Move one of the user's plans into the team's shared folder. The plan
817
+ * keeps its id: the manifest entry stays and gets the `shared` flag, so a
818
+ * session that attached the plan still finds it. The markdown moves first,
819
+ * then the manifest is written; a failure in between leaves the file in the
820
+ * shared folder (listed there as `shared:<file>`) and a dead personal entry
821
+ * that `readPlan` reports as gone. Needs a shared plans folder; refused
822
+ * (validation error) when the project has none.
823
+ */
824
+ const sharePlan = async (projectId, planId) => {
825
+ const id = asNonEmptyString(planId);
826
+ if (!id) throw new Error('planId is required');
827
+ return withWriteLock(projectId, async () => {
828
+ const dir = await sharedPlansDirFor(projectId);
829
+ if (!dir) throw new Error('shared plans folder is required');
830
+ const current = await readStoredContext(projectId);
831
+ const link = current.plans.find((entry) => entry.id === id);
832
+ if (!link || link.shared) return null;
833
+ const from = path.join(plansDirFor(projectId), link.file);
834
+ const exists = await fsPromises.access(from).then(() => true, () => false);
835
+ if (!exists) return null;
836
+ await fsPromises.mkdir(dir, { recursive: true });
837
+ const file = await freeFileNameIn(dir, link.file);
838
+ await moveFile(from, path.join(dir, file));
839
+ const moved = { ...link, file, shared: true };
840
+ await writeContext(projectId, { ...current, plans: current.plans.map((entry) => (entry.id === id ? moved : entry)) });
841
+ const context = await readContext(projectId);
842
+ return { plan: context.plans.find((entry) => entry.id === id), context };
843
+ });
844
+ };
845
+
846
+ /**
847
+ * Move a shared plan back into the user's own plans; the reverse of
848
+ * `sharePlan`. A plan the user moved keeps its id; a plan that only ever
849
+ * lived in the team's folder (`shared:<file>`) gets a manifest entry now.
850
+ */
851
+ const unsharePlan = async (projectId, planId) => {
852
+ const id = asNonEmptyString(planId);
853
+ if (!id) throw new Error('planId is required');
854
+ return withWriteLock(projectId, async () => {
855
+ const dir = await sharedPlansDirFor(projectId);
856
+ if (!dir) return null;
857
+ const current = await readStoredContext(projectId);
858
+ const ownLink = current.plans.find((entry) => entry.id === id && entry.shared);
859
+ const sharedFile = ownLink ? ownLink.file : sharedPlanFileOf(id);
860
+ if (!sharedFile) return null;
861
+ const from = path.join(dir, sharedFile);
862
+ let raw;
863
+ try {
864
+ raw = await fsPromises.readFile(from, 'utf8');
865
+ } catch (error) {
866
+ if (error && error.code === 'ENOENT') return null;
867
+ throw error;
868
+ }
869
+ const plansDir = plansDirFor(projectId);
870
+ await fsPromises.mkdir(plansDir, { recursive: true });
871
+ const taken = new Set(current.plans.filter((entry) => !entry.shared).map((entry) => entry.file));
872
+ const file = await freeFileNameIn(plansDir, sharedFile, taken);
873
+ await moveFile(from, path.join(plansDir, file));
874
+ const title = parsePlanMarkdown(raw).title;
875
+ const link = ownLink
876
+ ? { ...ownLink, file, title, shared: false }
877
+ : { id: idFactory(), file, title, createdAt: Date.now(), pinned: false, shared: false };
878
+ const plans = ownLink
879
+ ? current.plans.map((entry) => (entry.id === id ? link : entry))
880
+ : [link, ...current.plans];
881
+ await writeContext(projectId, { ...current, plans });
882
+ const { shared: _movedFlag, ...publicLink } = link;
883
+ return { plan: { ...publicLink, source: 'personal' }, context: await readContext(projectId) };
655
884
  });
656
885
  };
657
886
 
@@ -666,6 +895,8 @@ export const createProjectContextRuntime = (deps) => {
666
895
  createPlan,
667
896
  setPlanPinned,
668
897
  deletePlan,
898
+ sharePlan,
899
+ unsharePlan,
669
900
  contextPathFor,
670
901
  plansDirFor,
671
902
  };
@@ -3,6 +3,7 @@ import fsPromises from 'node:fs/promises';
3
3
  import os from 'node:os';
4
4
  import path from 'node:path';
5
5
 
6
+ import { createProjectIdFromPath, projectConfigFileStemOf } from '../projects/project-id.js';
6
7
  import { createProjectContextRuntime, parsePlanMarkdown } from './runtime.js';
7
8
 
8
9
  const PROJECT_ID = 'path_dGVzdA';
@@ -52,6 +53,7 @@ describe('readContext', () => {
52
53
  notes: [],
53
54
  todos: [],
54
55
  plans: [],
56
+ sharedPlansDir: null,
55
57
  });
56
58
  });
57
59
 
@@ -134,7 +136,7 @@ describe('legacy migration', () => {
134
136
  const context = await runtime.readContext(PROJECT_ID);
135
137
  expect(context.notes.map((note) => note.body)).toEqual(['legacy notes']);
136
138
  expect(context.todos).toEqual([{ id: 't1', text: 'legacy todo', completed: true, createdAt: 5 }]);
137
- expect(context.plans).toEqual([{ id: 'p1', file: '10-old.md', title: 'Old plan', createdAt: 10, pinned: false }]);
139
+ expect(context.plans).toEqual([{ id: 'p1', file: '10-old.md', title: 'Old plan', createdAt: 10, pinned: false, source: 'personal' }]);
138
140
 
139
141
  const remaining = await readJson(legacyConfigPath());
140
142
  expect(remaining).toEqual({
@@ -152,7 +154,7 @@ describe('legacy migration', () => {
152
154
  });
153
155
 
154
156
  const context = await runtime.readContext(PROJECT_ID);
155
- expect(context.plans).toEqual([{ id: 'p1', file: 'stray.md', title: 'Stray', createdAt: 10, pinned: false }]);
157
+ expect(context.plans).toEqual([{ id: 'p1', file: 'stray.md', title: 'Stray', createdAt: 10, pinned: false, source: 'personal' }]);
156
158
  expect(await fsPromises.readFile(path.join(plansDir(), 'stray.md'), 'utf8')).toContain('recovered');
157
159
  });
158
160
 
@@ -170,7 +172,7 @@ describe('legacy migration', () => {
170
172
  test('does not run when the legacy config holds no context keys', async () => {
171
173
  await writeJson(legacyConfigPath(), { 'setup-worktree': ['bun install'] });
172
174
 
173
- expect(await runtime.readContext(PROJECT_ID)).toEqual({ version: 2, notes: [], todos: [], plans: [] });
175
+ expect(await runtime.readContext(PROJECT_ID)).toEqual({ version: 2, notes: [], todos: [], plans: [], sharedPlansDir: null });
174
176
  await expect(fsPromises.access(contextPath())).rejects.toThrow();
175
177
  expect(await readJson(legacyConfigPath())).toEqual({ 'setup-worktree': ['bun install'] });
176
178
  });
@@ -199,6 +201,59 @@ describe('legacy migration', () => {
199
201
  });
200
202
  });
201
203
 
204
+ describe('long project ids', () => {
205
+ // A checkout nested deep enough that its `path_<base64url>` id is longer
206
+ // than a file or folder name may be; the storage lands under the bounded
207
+ // stem instead of failing with ENAMETOOLONG.
208
+ const longProjectId = createProjectIdFromPath(`/private/tmp/claude-501/${'segment-'.repeat(18)}/scratchpad/evidence/demo-repo`);
209
+ const stem = projectConfigFileStemOf(longProjectId);
210
+
211
+ test('the id maps to a bounded folder name', () => {
212
+ expect(longProjectId.length).toBeGreaterThan(240);
213
+ expect(stem.startsWith('path_sha256_')).toBe(true);
214
+ expect(stem.length).toBeLessThan(100);
215
+ expect(runtime.contextPathFor(longProjectId)).toBe(path.join(projectsDirPath, stem, 'context.json'));
216
+ expect(runtime.plansDirFor(longProjectId)).toBe(path.join(projectsDirPath, stem, 'plans'));
217
+ // A short id keeps naming its folder itself.
218
+ expect(runtime.contextPathFor(PROJECT_ID)).toBe(contextPath());
219
+ });
220
+
221
+ test('a missing context reads as empty instead of failing on the legacy config lookup', async () => {
222
+ expect(await runtime.readContext(longProjectId)).toEqual({ version: 2, notes: [], todos: [], plans: [], sharedPlansDir: null });
223
+ });
224
+
225
+ test('context and plans round-trip through the bounded folder', async () => {
226
+ await runtime.saveTodos(longProjectId, [{ id: 't1', text: 'do it', completed: false, createdAt: 1 }]);
227
+ const { note } = await runtime.createNote(longProjectId, { body: 'kept' });
228
+ const { plan } = await runtime.createPlan(longProjectId, { title: 'Deep plan', body: 'step one' });
229
+
230
+ const context = await runtime.readContext(longProjectId);
231
+ expect(context.todos).toEqual([{ id: 't1', text: 'do it', completed: false, createdAt: 1 }]);
232
+ expect(context.notes.map((entry) => entry.id)).toEqual([note.id]);
233
+ expect(context.plans.map((entry) => entry.id)).toEqual([plan.id]);
234
+ expect((await runtime.readPlan(longProjectId, plan.id)).body).toBe('step one');
235
+
236
+ expect((await readJson(path.join(projectsDirPath, stem, 'context.json'))).todos).toHaveLength(1);
237
+ await fsPromises.access(path.join(projectsDirPath, stem, 'plans', plan.file));
238
+ expect(await fsPromises.readdir(projectsDirPath)).toEqual([stem]);
239
+ });
240
+
241
+ test('legacy keys are migrated out of the bounded config file', async () => {
242
+ const boundedConfigPath = path.join(projectsDirPath, `${stem}.json`);
243
+ await writeJson(boundedConfigPath, {
244
+ 'setup-worktree': ['bun install'],
245
+ projectNotes: 'legacy notes',
246
+ projectTodos: [{ id: 't1', text: 'legacy todo', completed: true, createdAt: 5 }],
247
+ });
248
+
249
+ const context = await runtime.readContext(longProjectId);
250
+ expect(context.notes.map((entry) => entry.body)).toEqual(['legacy notes']);
251
+ expect(context.todos).toEqual([{ id: 't1', text: 'legacy todo', completed: true, createdAt: 5 }]);
252
+ expect(await readJson(boundedConfigPath)).toEqual({ 'setup-worktree': ['bun install'] });
253
+ expect((await readJson(path.join(projectsDirPath, stem, 'context.json'))).notes[0].body).toBe('legacy notes');
254
+ });
255
+ });
256
+
202
257
  describe('todos', () => {
203
258
  test('round-trips through disk', async () => {
204
259
  await runtime.saveTodos(PROJECT_ID, [{ id: 't1', text: 'do it', completed: false, createdAt: 1 }]);
@@ -479,6 +534,108 @@ describe('plans', () => {
479
534
  });
480
535
  });
481
536
 
537
+ describe('shared plans', () => {
538
+ let sharedDir;
539
+ let sharedRuntime;
540
+
541
+ beforeEach(async () => {
542
+ sharedDir = path.join(projectsDirPath, 'repo', 'docs', 'plans');
543
+ sharedRuntime = createProjectContextRuntime({
544
+ fsPromises,
545
+ path,
546
+ projectsDirPath,
547
+ createId: () => `plan-${++idCounter}`,
548
+ resolveSharedPlansDir: async () => sharedDir,
549
+ });
550
+ });
551
+
552
+ test('lists the shared folder\'s markdown files after the personal plans, marked shared, and reads them by file', async () => {
553
+ await fsPromises.mkdir(sharedDir, { recursive: true });
554
+ await fsPromises.writeFile(path.join(sharedDir, 'roadmap.md'), '# Roadmap\n\n- ship it\n');
555
+ await fsPromises.writeFile(path.join(sharedDir, 'notes.txt'), 'not a plan');
556
+ await fsPromises.writeFile(path.join(sharedDir, 'untitled.md'), 'first line only');
557
+ const mine = await sharedRuntime.createPlan(PROJECT_ID, { title: 'Mine', body: 'x' });
558
+
559
+ const context = await sharedRuntime.readContext(PROJECT_ID);
560
+ expect(context.sharedPlansDir).toBe(sharedDir);
561
+ expect(context.plans.map((plan) => `${plan.id}:${plan.source}`)).toEqual(expect.arrayContaining([
562
+ `${mine.plan.id}:personal`, 'shared:roadmap.md:shared', 'shared:untitled.md:shared',
563
+ ]));
564
+ expect(context.plans[0].id).toBe(mine.plan.id);
565
+ expect(context.plans.find((plan) => plan.id === 'shared:untitled.md').title).toBe('first line only');
566
+
567
+ const read = await sharedRuntime.readPlan(PROJECT_ID, 'shared:roadmap.md');
568
+ expect(read.title).toBe('Roadmap');
569
+ expect(read.raw).toBe('# Roadmap\n\n- ship it\n');
570
+ expect(await sharedRuntime.readPlan(PROJECT_ID, 'shared:missing.md')).toBeNull();
571
+ expect(await sharedRuntime.readPlan(PROJECT_ID, 'shared:../escape.md')).toBeNull();
572
+ });
573
+
574
+ test('a missing shared folder lists nothing; no folder configured lists nothing', async () => {
575
+ expect((await sharedRuntime.readContext(PROJECT_ID)).plans).toEqual([]);
576
+ expect((await runtime.readContext(PROJECT_ID)).sharedPlansDir).toBeNull();
577
+ });
578
+
579
+ test('updates and deletes a shared plan in place, verbatim', async () => {
580
+ await fsPromises.mkdir(sharedDir, { recursive: true });
581
+ await fsPromises.writeFile(path.join(sharedDir, 'a.md'), '# A\n');
582
+ const updated = await sharedRuntime.updatePlan(PROJECT_ID, 'shared:a.md', { raw: '# Renamed\n\nbody\n' });
583
+ expect(updated.plan.title).toBe('Renamed');
584
+ expect(updated.plan.source).toBe('shared');
585
+ expect(await fsPromises.readFile(path.join(sharedDir, 'a.md'), 'utf8')).toBe('# Renamed\n\nbody\n');
586
+ expect(await sharedRuntime.updatePlan(PROJECT_ID, 'shared:gone.md', { raw: 'x' })).toBeNull();
587
+ expect(await sharedRuntime.setPlanPinned(PROJECT_ID, 'shared:a.md', true)).toBeNull();
588
+
589
+ const deleted = await sharedRuntime.deletePlan(PROJECT_ID, 'shared:a.md');
590
+ expect(deleted.deleted).toBe(true);
591
+ await expect(fsPromises.access(path.join(sharedDir, 'a.md'))).rejects.toThrow();
592
+ expect((await sharedRuntime.deletePlan(PROJECT_ID, 'shared:a.md')).deleted).toBe(false);
593
+ });
594
+
595
+ test('share moves a personal plan into the shared folder and unshare brings it back, avoiding name collisions', async () => {
596
+ const { plan } = await sharedRuntime.createPlan(PROJECT_ID, { title: 'Mine', body: 'body' });
597
+ const shared = await sharedRuntime.sharePlan(PROJECT_ID, plan.id);
598
+ // The id survives the move: a session that attached the plan still finds it.
599
+ expect(shared.plan.id).toBe(plan.id);
600
+ expect(shared.plan.source).toBe('shared');
601
+ expect(shared.context.plans.map((entry) => `${entry.id}:${entry.source}`)).toEqual([`${plan.id}:shared`]);
602
+ await expect(fsPromises.access(path.join(plansDir(), plan.file))).rejects.toThrow();
603
+ expect(await fsPromises.readFile(path.join(sharedDir, plan.file), 'utf8')).toBe('# Mine\n\nbody');
604
+ expect((await readJson(path.join(projectsDirPath, PROJECT_ID, 'context.json'))).plans[0]).toMatchObject({ id: plan.id, shared: true });
605
+ const readMoved = await sharedRuntime.readPlan(PROJECT_ID, plan.id);
606
+ expect(readMoved.source).toBe('shared');
607
+ expect(readMoved.raw).toBe('# Mine\n\nbody');
608
+ expect((await sharedRuntime.updatePlan(PROJECT_ID, plan.id, { raw: '# Mine v2\n' })).plan).toMatchObject({ id: plan.id, title: 'Mine v2', source: 'shared' });
609
+ expect(await fsPromises.readFile(path.join(sharedDir, plan.file), 'utf8')).toBe('# Mine v2\n');
610
+
611
+ // A personal file with the same name already exists: the returning plan gets a suffix, same id.
612
+ await fsPromises.mkdir(plansDir(), { recursive: true });
613
+ await fsPromises.writeFile(path.join(plansDir(), plan.file), 'squatter');
614
+ const back = await sharedRuntime.unsharePlan(PROJECT_ID, plan.id);
615
+ expect(back.plan.id).toBe(plan.id);
616
+ expect(back.plan.source).toBe('personal');
617
+ expect(back.plan.file).toBe(plan.file.replace(/\.md$/, '-1.md'));
618
+ expect(back.plan.title).toBe('Mine v2');
619
+ expect(back.context.plans.map((entry) => `${entry.id}:${entry.source}`)).toEqual([`${plan.id}:personal`]);
620
+ await expect(fsPromises.access(path.join(sharedDir, plan.file))).rejects.toThrow();
621
+ expect(await sharedRuntime.unsharePlan(PROJECT_ID, plan.id)).toBeNull();
622
+ expect(await sharedRuntime.sharePlan(PROJECT_ID, 'nope')).toBeNull();
623
+
624
+ // A plan that only ever lived in the team's folder gets an id of its own on the way in.
625
+ await fsPromises.writeFile(path.join(sharedDir, 'foreign.md'), '# Foreign\n');
626
+ const adopted = await sharedRuntime.unsharePlan(PROJECT_ID, 'shared:foreign.md');
627
+ expect(adopted.plan.id).not.toMatch(/^shared:/);
628
+ expect(adopted.plan.title).toBe('Foreign');
629
+ expect(adopted.plan.source).toBe('personal');
630
+ });
631
+
632
+ test('share is refused without a shared plans folder', async () => {
633
+ const { plan } = await runtime.createPlan(PROJECT_ID, { title: 'Mine', body: 'x' });
634
+ await expect(runtime.sharePlan(PROJECT_ID, plan.id)).rejects.toThrow('shared plans folder is required');
635
+ expect((await runtime.readContext(PROJECT_ID)).plans.map((entry) => entry.id)).toEqual([plan.id]);
636
+ });
637
+ });
638
+
482
639
  describe('parsePlanMarkdown', () => {
483
640
  test('reads the leading heading as the title', () => {
484
641
  expect(parsePlanMarkdown('# Title\n\nbody')).toEqual({ title: 'Title', body: 'body' });