pmx-canvas 0.8.2 → 0.9.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 (338) hide show
  1. package/.github/extensions/pmx-canvas/runtime.json +1 -1
  2. package/.github/extensions/pmx-canvas/tools.json +601 -22
  3. package/CHANGELOG.md +59 -0
  4. package/Readme.md +131 -532
  5. package/dist/canvas/global.css +2724 -476
  6. package/dist/canvas/index.js +168 -107
  7. package/dist/canvas/mcp-app.html +2899 -596
  8. package/dist/json-render/index.js +20 -20
  9. package/dist/types/client/canvas/BarHint.d.ts +12 -2
  10. package/dist/types/client/canvas/BoardLinkDialog.d.ts +3 -1
  11. package/dist/types/client/canvas/BoardPinButton.d.ts +10 -0
  12. package/dist/types/client/canvas/BoardSwitcher.d.ts +1 -1
  13. package/dist/types/client/canvas/CanvasViewport.d.ts +1 -1
  14. package/dist/types/client/canvas/CommandBar.d.ts +3 -4
  15. package/dist/types/client/canvas/ConnectionBanner.d.ts +2 -1
  16. package/dist/types/client/canvas/ContextChip.d.ts +3 -0
  17. package/dist/types/client/canvas/CrossBoardReferences.d.ts +5 -0
  18. package/dist/types/client/canvas/EdgeLayer.d.ts +14 -3
  19. package/dist/types/client/canvas/EmptyState.d.ts +4 -4
  20. package/dist/types/client/canvas/HomeGraphView.d.ts +2 -0
  21. package/dist/types/client/canvas/HomeMapView.d.ts +1 -0
  22. package/dist/types/client/canvas/HomeReviewView.d.ts +6 -0
  23. package/dist/types/client/canvas/HomeView.d.ts +9 -7
  24. package/dist/types/client/canvas/KnowledgeDialog.d.ts +4 -0
  25. package/dist/types/client/canvas/LifecycleChrome.d.ts +5 -0
  26. package/dist/types/client/canvas/LifecycleFields.d.ts +7 -0
  27. package/dist/types/client/canvas/LifecyclePanel.d.ts +1 -0
  28. package/dist/types/client/canvas/MemoryInspector.d.ts +7 -0
  29. package/dist/types/client/canvas/NearPinLayer.d.ts +14 -0
  30. package/dist/types/client/canvas/NodeContextMark.d.ts +40 -7
  31. package/dist/types/client/canvas/RelationPresetsDialog.d.ts +4 -0
  32. package/dist/types/client/canvas/RelationsPanel.d.ts +4 -0
  33. package/dist/types/client/canvas/SessionPanel.d.ts +2 -0
  34. package/dist/types/client/canvas/canvas-area.d.ts +1 -1
  35. package/dist/types/client/canvas/graph-geometry.d.ts +27 -0
  36. package/dist/types/client/canvas/graph-model.d.ts +37 -0
  37. package/dist/types/client/canvas/memory-layout.d.ts +23 -0
  38. package/dist/types/client/canvas/use-chrome-height.d.ts +2 -0
  39. package/dist/types/client/canvas/use-focus-trap.d.ts +1 -0
  40. package/dist/types/client/canvas/use-memory-graph.d.ts +8 -0
  41. package/dist/types/client/canvas/use-pan-zoom.d.ts +4 -1
  42. package/dist/types/client/icons.d.ts +0 -10
  43. package/dist/types/client/nodes/AxStepControls.d.ts +1 -1
  44. package/dist/types/client/nodes/InlineMarkdownEditor.d.ts +4 -4
  45. package/dist/types/client/nodes/MarkdownNode.d.ts +5 -1
  46. package/dist/types/client/nodes/McpAppNode.d.ts +4 -4
  47. package/dist/types/client/nodes/viewer-runtime-state.d.ts +1 -1
  48. package/dist/types/client/state/boards-store.d.ts +81 -9
  49. package/dist/types/client/state/canvas-store.d.ts +14 -1
  50. package/dist/types/client/state/context-chip-store.d.ts +22 -0
  51. package/dist/types/client/state/context-status-store.d.ts +35 -0
  52. package/dist/types/client/state/human-store.d.ts +2 -1
  53. package/dist/types/client/state/intent-bridge.d.ts +11 -8
  54. package/dist/types/client/state/knowledge-store.d.ts +22 -0
  55. package/dist/types/client/state/near-pin-store.d.ts +44 -1
  56. package/dist/types/client/state/presence-store.d.ts +4 -4
  57. package/dist/types/client/state/presentation.d.ts +28 -0
  58. package/dist/types/client/state/relation-store.d.ts +10 -0
  59. package/dist/types/client/state/session-store.d.ts +41 -2
  60. package/dist/types/client/state/workspace-settings.d.ts +7 -0
  61. package/dist/types/client/types.d.ts +13 -1
  62. package/dist/types/client/utils/keyboard.d.ts +2 -0
  63. package/dist/types/client/utils/time-ago.d.ts +4 -0
  64. package/dist/types/mcp/context-uri-template.d.ts +3 -1
  65. package/dist/types/server/agent-presence.d.ts +10 -3
  66. package/dist/types/server/ax-context.d.ts +1 -1
  67. package/dist/types/server/canvas-db.d.ts +30 -7
  68. package/dist/types/server/canvas-operations.d.ts +24 -2
  69. package/dist/types/server/canvas-provenance.d.ts +22 -2
  70. package/dist/types/server/canvas-serialization.d.ts +2 -0
  71. package/dist/types/server/canvas-state.d.ts +93 -15
  72. package/dist/types/server/canvas-validation.d.ts +1 -1
  73. package/dist/types/server/context-brief.d.ts +94 -6
  74. package/dist/types/server/context-reads.d.ts +33 -7
  75. package/dist/types/server/edit-summary.d.ts +20 -0
  76. package/dist/types/server/index.d.ts +71 -10
  77. package/dist/types/server/knowledge-lifecycle.d.ts +221 -0
  78. package/dist/types/server/lifecycle-status.d.ts +10 -0
  79. package/dist/types/server/memory-graph.d.ts +9 -0
  80. package/dist/types/server/mutation-history.d.ts +2 -0
  81. package/dist/types/server/operations/ops/ax-shared.d.ts +1 -1
  82. package/dist/types/server/operations/ops/boards.d.ts +2 -1
  83. package/dist/types/server/operations/ops/lifecycle.d.ts +2 -0
  84. package/dist/types/server/operations/ops/memory-graph.d.ts +2 -0
  85. package/dist/types/server/operations/ops/nodes.d.ts +5 -0
  86. package/dist/types/server/operations/ops/query.d.ts +23 -0
  87. package/dist/types/server/operations/registry.d.ts +1 -0
  88. package/dist/types/server/placement.d.ts +7 -0
  89. package/dist/types/server/scope-fence.d.ts +2 -1
  90. package/dist/types/server/spatial-analysis.d.ts +6 -3
  91. package/dist/types/server/tour-control.d.ts +32 -0
  92. package/dist/types/server/wiki-links.d.ts +14 -0
  93. package/dist/types/server/workbench-auth.d.ts +2 -0
  94. package/dist/types/shared/agent-context.d.ts +2 -0
  95. package/dist/types/shared/agent-presence.d.ts +15 -12
  96. package/dist/types/shared/auto-arrange.d.ts +1 -0
  97. package/dist/types/shared/boards.d.ts +4 -4
  98. package/dist/types/shared/knowledge-lifecycle.d.ts +41 -0
  99. package/dist/types/shared/knowledge-presets.d.ts +6 -0
  100. package/dist/types/shared/lifecycle-status.d.ts +24 -0
  101. package/dist/types/shared/memory-graph.d.ts +53 -0
  102. package/dist/types/shared/relations.d.ts +68 -0
  103. package/dist/types/shared/semantic-attention.d.ts +2 -0
  104. package/dist/types/shared/tour.d.ts +38 -0
  105. package/dist/types/shared/workspace-settings.d.ts +11 -0
  106. package/docs/RELEASE.md +21 -3
  107. package/docs/adr-001-bun-only-runtime.md +7 -1
  108. package/docs/adr-002-shared-product-separate-runtimes.md +93 -0
  109. package/docs/api-reference.md +109 -0
  110. package/docs/ax-host-adapter-contract.md +1 -1
  111. package/docs/ax-state-contract.md +5 -4
  112. package/docs/cli.md +153 -15
  113. package/docs/demos/pmx-canvas-demo.mp4 +0 -0
  114. package/docs/design/AgentContext.dc.html +3 -3
  115. package/docs/design/BoardPins.dc.html +37 -22
  116. package/docs/design/Categories.dc.html +208 -0
  117. package/docs/design/CategoryEditor.dc.html +33 -0
  118. package/docs/design/Chrome.dc.html +52 -9
  119. package/docs/design/ContextChip.dc.html +224 -0
  120. package/docs/design/ContextChip600.dc.html +136 -0
  121. package/docs/design/EmptyState.dc.html +45 -0
  122. package/docs/design/Flows.dc.html +23 -1
  123. package/docs/design/HeaderMarks.dc.html +183 -0
  124. package/docs/design/Home.dc.html +19 -1
  125. package/docs/design/HomeGraph.dc.html +86 -202
  126. package/docs/design/HomeMap.dc.html +23 -1
  127. package/docs/design/HomeTeam.dc.html +32 -14
  128. package/docs/design/Lifecycle.dc.html +22 -0
  129. package/docs/design/Lifecycle600.dc.html +18 -0
  130. package/docs/design/LifecycleVariants.dc.html +157 -0
  131. package/docs/design/LinkAuthorship.dc.html +139 -0
  132. package/docs/design/LinksChanged.dc.html +203 -0
  133. package/docs/design/LinksOptions.dc.html +137 -0
  134. package/docs/design/LinksOptions600.dc.html +134 -0
  135. package/docs/design/NearPin.dc.html +9 -10
  136. package/docs/design/Pane600.dc.html +4 -3
  137. package/docs/design/Relations.dc.html +3 -2
  138. package/docs/design/Restore.dc.html +152 -0
  139. package/docs/design/SeeChange.dc.html +267 -0
  140. package/docs/design/Uses.dc.html +12 -6
  141. package/docs/design/canvas.json +146 -19
  142. package/docs/design.md +449 -16
  143. package/docs/environment.md +0 -1
  144. package/docs/evals/curation-effect.md +128 -0
  145. package/docs/http-api.md +459 -37
  146. package/docs/mcp.md +146 -18
  147. package/docs/node-types.md +58 -3
  148. package/docs/plans/design-gaps-2026-10-08.md +53 -0
  149. package/docs/plans/plan-009-tech-debt-backlog.md +379 -0
  150. package/docs/plans/plan-016-context-made-visible-wave-1.md +150 -5
  151. package/docs/plans/plan-017-knowledge-categories-and-lifecycle.md +175 -0
  152. package/docs/plans/plan-018-connected-memory-wave-2.md +594 -0
  153. package/docs/plans/plan-019-post-0.9-roadmap.md +198 -0
  154. package/docs/product-context-vision-2026-09.md +17 -1
  155. package/docs/product-vision-2026-09.md +1082 -43
  156. package/docs/product-vision-review-2026-09.md +13 -1
  157. package/docs/screenshots/workbench-live-dark.png +0 -0
  158. package/docs/screenshots/workbench-live-light.png +0 -0
  159. package/docs/sdk.md +62 -2
  160. package/package.json +1 -1
  161. package/plugins/pmx-canvas/com.github.copilot/extensions/pmx-canvas/runtime.json +1 -1
  162. package/plugins/pmx-canvas/plugin.json +1 -1
  163. package/plugins/pmx-canvas/runtime.json +1 -1
  164. package/plugins/pmx-canvas/tools.json +601 -22
  165. package/skills/pmx-canvas/SKILL.md +216 -42
  166. package/skills/pmx-canvas/evals/evals.json +224 -0
  167. package/skills/pmx-canvas/references/full-reference.md +120 -21
  168. package/skills/pmx-canvas/references/github-copilot-app-adapter.md +2 -2
  169. package/skills/pmx-canvas/references/importing-context.md +132 -0
  170. package/skills/pmx-canvas/references/installing-pmx-canvas.md +6 -6
  171. package/skills/pmx-canvas/references/tours-and-recording.md +16 -1
  172. package/skills/pmx-canvas-testing/SKILL.md +20 -0
  173. package/src/cli/agent.ts +2 -0
  174. package/src/cli/commands/ax.ts +12 -0
  175. package/src/cli/commands/boards.ts +88 -11
  176. package/src/cli/commands/edges.ts +28 -0
  177. package/src/cli/commands/groups.ts +7 -0
  178. package/src/cli/commands/nodes.ts +1 -0
  179. package/src/cli/commands/query.ts +34 -2
  180. package/src/cli/commands/record.ts +199 -48
  181. package/src/cli/index.ts +6 -1
  182. package/src/client/App.tsx +177 -70
  183. package/src/client/canvas/AttentionHistory.tsx +0 -2
  184. package/src/client/canvas/BarHint.tsx +40 -2
  185. package/src/client/canvas/BoardCopyDialog.tsx +1 -1
  186. package/src/client/canvas/BoardFolderDialog.tsx +7 -7
  187. package/src/client/canvas/BoardLinkDialog.tsx +138 -33
  188. package/src/client/canvas/BoardPinButton.tsx +33 -0
  189. package/src/client/canvas/BoardSwitcher.tsx +26 -40
  190. package/src/client/canvas/CanvasNode.tsx +360 -237
  191. package/src/client/canvas/CanvasViewport.tsx +45 -7
  192. package/src/client/canvas/CommandBar.tsx +29 -23
  193. package/src/client/canvas/CommandPalette.tsx +5 -12
  194. package/src/client/canvas/ConnectionBanner.tsx +3 -3
  195. package/src/client/canvas/ContextChip.tsx +296 -0
  196. package/src/client/canvas/ContextMenu.tsx +63 -5
  197. package/src/client/canvas/CrossBoardReferences.tsx +60 -0
  198. package/src/client/canvas/EdgeLayer.tsx +339 -76
  199. package/src/client/canvas/EmptyState.tsx +33 -18
  200. package/src/client/canvas/ExpandedNodeOverlay.tsx +72 -40
  201. package/src/client/canvas/ExternalWriters.tsx +57 -42
  202. package/src/client/canvas/HomeGraphView.tsx +942 -0
  203. package/src/client/canvas/HomeMapView.tsx +641 -0
  204. package/src/client/canvas/HomeReviewView.tsx +167 -0
  205. package/src/client/canvas/HomeView.tsx +687 -160
  206. package/src/client/canvas/IntentLayer.tsx +19 -12
  207. package/src/client/canvas/KnowledgeDialog.tsx +488 -0
  208. package/src/client/canvas/LifecycleChrome.tsx +133 -0
  209. package/src/client/canvas/LifecycleFields.tsx +91 -0
  210. package/src/client/canvas/LifecyclePanel.tsx +543 -0
  211. package/src/client/canvas/MemoryInspector.tsx +104 -0
  212. package/src/client/canvas/Minimap.tsx +3 -3
  213. package/src/client/canvas/NearPinLayer.tsx +187 -0
  214. package/src/client/canvas/NodeContextMark.tsx +175 -97
  215. package/src/client/canvas/Presentation.tsx +23 -57
  216. package/src/client/canvas/RelationPresetsDialog.tsx +413 -0
  217. package/src/client/canvas/RelationsPanel.tsx +82 -0
  218. package/src/client/canvas/SelectionBar.tsx +3 -6
  219. package/src/client/canvas/SessionPanel.tsx +90 -50
  220. package/src/client/canvas/SessionReceipt.tsx +311 -39
  221. package/src/client/canvas/ShortcutOverlay.tsx +1 -1
  222. package/src/client/canvas/SnapshotPanel.tsx +27 -36
  223. package/src/client/canvas/ToolRail.tsx +367 -192
  224. package/src/client/canvas/TopBar.tsx +354 -124
  225. package/src/client/canvas/canvas-area.ts +10 -3
  226. package/src/client/canvas/graph-geometry.ts +195 -0
  227. package/src/client/canvas/graph-model.ts +254 -0
  228. package/src/client/canvas/memory-layout.ts +106 -0
  229. package/src/client/canvas/use-chrome-height.ts +20 -0
  230. package/src/client/canvas/use-focus-trap.ts +31 -5
  231. package/src/client/canvas/use-memory-graph.ts +58 -0
  232. package/src/client/canvas/use-node-drag.ts +2 -1
  233. package/src/client/canvas/use-pan-zoom.ts +64 -7
  234. package/src/client/icons.tsx +0 -63
  235. package/src/client/mcp-app/style.css +0 -6
  236. package/src/client/nodes/AxStepControls.tsx +74 -50
  237. package/src/client/nodes/BoardNode.tsx +5 -1
  238. package/src/client/nodes/ContextNode.tsx +18 -11
  239. package/src/client/nodes/ExtAppFrame.tsx +6 -3
  240. package/src/client/nodes/FileNode.tsx +46 -42
  241. package/src/client/nodes/HtmlNode.tsx +32 -0
  242. package/src/client/nodes/ImageNode.tsx +1 -1
  243. package/src/client/nodes/InlineFormatBar.tsx +6 -9
  244. package/src/client/nodes/InlineMarkdownEditor.tsx +56 -17
  245. package/src/client/nodes/MarkdownNode.tsx +377 -109
  246. package/src/client/nodes/McpAppNode.tsx +50 -20
  247. package/src/client/nodes/MdFormatBar.tsx +21 -18
  248. package/src/client/nodes/PromptNode.tsx +1 -1
  249. package/src/client/nodes/ResponseNode.tsx +1 -1
  250. package/src/client/nodes/viewer-runtime-state.ts +54 -22
  251. package/src/client/state/attention-bridge.ts +64 -15
  252. package/src/client/state/boards-store.ts +193 -39
  253. package/src/client/state/canvas-store.ts +99 -12
  254. package/src/client/state/context-chip-store.ts +70 -0
  255. package/src/client/state/context-status-store.ts +53 -0
  256. package/src/client/state/human-store.ts +13 -7
  257. package/src/client/state/intent-bridge.ts +51 -30
  258. package/src/client/state/knowledge-store.ts +43 -0
  259. package/src/client/state/near-pin-store.ts +115 -6
  260. package/src/client/state/presence-store.ts +7 -10
  261. package/src/client/state/presentation.ts +91 -0
  262. package/src/client/state/relation-store.ts +81 -0
  263. package/src/client/state/session-store.ts +144 -18
  264. package/src/client/state/sse-bridge.ts +235 -32
  265. package/src/client/state/workspace-settings.ts +11 -0
  266. package/src/client/theme/global.css +2724 -476
  267. package/src/client/types.ts +12 -0
  268. package/src/client/utils/keyboard.ts +10 -0
  269. package/src/client/utils/time-ago.ts +14 -0
  270. package/src/json-render/renderer/index.tsx +31 -0
  271. package/src/mcp/canvas-app.ts +12 -2
  272. package/src/mcp/context-uri-template.ts +7 -4
  273. package/src/mcp/server.ts +63 -41
  274. package/src/mcp/workbench-app.ts +39 -6
  275. package/src/server/agent-presence.ts +128 -28
  276. package/src/server/ax-context.ts +27 -13
  277. package/src/server/board-export.ts +12 -1
  278. package/src/server/canvas-db.ts +331 -42
  279. package/src/server/canvas-operations.ts +195 -83
  280. package/src/server/canvas-provenance.ts +53 -22
  281. package/src/server/canvas-serialization.ts +2 -0
  282. package/src/server/canvas-state.ts +950 -122
  283. package/src/server/canvas-validation.ts +9 -2
  284. package/src/server/context-brief.ts +345 -38
  285. package/src/server/context-reads.ts +178 -30
  286. package/src/server/demo-state.json +355 -24
  287. package/src/server/edit-summary.ts +74 -0
  288. package/src/server/html-surface.ts +8 -1
  289. package/src/server/index.ts +181 -11
  290. package/src/server/knowledge-lifecycle.ts +304 -0
  291. package/src/server/lifecycle-status.ts +232 -0
  292. package/src/server/memory-graph.ts +166 -0
  293. package/src/server/mutation-history.ts +18 -6
  294. package/src/server/operations/composites.ts +32 -11
  295. package/src/server/operations/http.ts +7 -2
  296. package/src/server/operations/index.ts +4 -0
  297. package/src/server/operations/mcp.ts +26 -1
  298. package/src/server/operations/ops/ax-presence.ts +1 -1
  299. package/src/server/operations/ops/ax-read.ts +57 -6
  300. package/src/server/operations/ops/ax-timeline.ts +28 -2
  301. package/src/server/operations/ops/backup.ts +5 -3
  302. package/src/server/operations/ops/batch.ts +35 -4
  303. package/src/server/operations/ops/boards.ts +188 -47
  304. package/src/server/operations/ops/canvas-wire.ts +31 -1
  305. package/src/server/operations/ops/edges.ts +36 -2
  306. package/src/server/operations/ops/groups.ts +6 -1
  307. package/src/server/operations/ops/lifecycle.ts +262 -0
  308. package/src/server/operations/ops/memory-graph.ts +45 -0
  309. package/src/server/operations/ops/nodes.ts +107 -24
  310. package/src/server/operations/ops/query.ts +281 -68
  311. package/src/server/operations/ops/snapshots.ts +73 -1
  312. package/src/server/operations/ops/validate.ts +5 -1
  313. package/src/server/operations/ops/viewport.ts +97 -3
  314. package/src/server/operations/registry.ts +74 -25
  315. package/src/server/placement.ts +29 -0
  316. package/src/server/scope-fence.ts +78 -6
  317. package/src/server/server.ts +115 -16
  318. package/src/server/spatial-analysis.ts +48 -23
  319. package/src/server/tour-control.ts +82 -0
  320. package/src/server/wiki-links.ts +137 -0
  321. package/src/server/workbench-auth.ts +11 -0
  322. package/src/shared/agent-context.ts +3 -0
  323. package/src/shared/agent-presence.ts +15 -15
  324. package/src/shared/auto-arrange.ts +2 -0
  325. package/src/shared/boards.ts +4 -4
  326. package/src/shared/knowledge-lifecycle.ts +22 -0
  327. package/src/shared/knowledge-presets.ts +80 -0
  328. package/src/shared/lifecycle-status.ts +26 -0
  329. package/src/shared/memory-graph.ts +74 -0
  330. package/src/shared/relations.ts +98 -0
  331. package/src/shared/semantic-attention.ts +15 -1
  332. package/src/shared/tour.ts +23 -5
  333. package/src/shared/workspace-settings.ts +13 -0
  334. package/dist/types/client/canvas/ContextPinBar.d.ts +0 -1
  335. package/dist/types/client/canvas/FocusFieldLayer.d.ts +0 -1
  336. package/dist/types/client/canvas/kind-colors.d.ts +0 -3
  337. package/dist/types/server/agent-context.d.ts +0 -22
  338. package/src/client/canvas/ContextPinBar.tsx +0 -25
@@ -0,0 +1,262 @@
1
+ import { z } from 'zod';
2
+ import { canvasState } from '../../canvas-state.js';
3
+ import { categorySchema, folderPropertiesSchema, lifecycleSchema } from '../../knowledge-lifecycle.js';
4
+ import { defineOperation, OperationError, type Operation } from '../types.js';
5
+ import { readTargetBoard } from './boards.js';
6
+ import { lifecycleQualifications, lifecycleSnapshot } from '../../lifecycle-status.js';
7
+ import { memoryKey } from '../../../shared/memory-graph.js';
8
+ import { folderAncestors } from '../../../shared/boards.js';
9
+ import { relationPresetSchema } from '../../../shared/relations.js';
10
+
11
+ const jsonResult = (result: unknown) => ({
12
+ content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }],
13
+ });
14
+ const target = { id: z.string().min(1), expectedBoardId: z.string().nullable().optional() };
15
+
16
+ export const lifecycleOperations: Operation[] = [
17
+ defineOperation({
18
+ name: 'knowledge.list',
19
+ mutates: false,
20
+ input: z.object({}),
21
+ inputShape: {},
22
+ http: { method: 'GET', path: '/api/canvas/knowledge' },
23
+ mcp: {
24
+ toolName: 'canvas_knowledge_list',
25
+ description: 'Read workspace categories and folder properties. No inferred defaults.',
26
+ formatResult: jsonResult,
27
+ },
28
+ handler: () => {
29
+ const catalog = canvasState.knowledge.list();
30
+ const paths = new Set([
31
+ ...catalog.folders.flatMap((folder) => folderAncestors(folder.path)),
32
+ ...canvasState.listBoards().flatMap((board) => (board.folder ? folderAncestors(board.folder) : [])),
33
+ ]);
34
+ return {
35
+ ok: true,
36
+ ...catalog,
37
+ ...lifecycleSnapshot(),
38
+ resolvedFolders: Object.fromEntries(
39
+ [...paths].map((path) => [path, canvasState.knowledge.resolve(path, null, catalog)]),
40
+ ),
41
+ };
42
+ },
43
+ }),
44
+ defineOperation({
45
+ name: 'knowledge.category-set',
46
+ mutates: false,
47
+ input: z.object({ category: categorySchema }),
48
+ inputShape: { category: categorySchema },
49
+ http: { method: 'POST', path: '/api/canvas/knowledge/category' },
50
+ mcp: {
51
+ toolName: 'canvas_knowledge_category_set',
52
+ description: 'Create or replace one workspace category by stable id.',
53
+ formatResult: jsonResult,
54
+ },
55
+ handler: (input, ctx) => {
56
+ const category = canvasState.knowledge.setCategory(input.category);
57
+ ctx.emit('knowledge-changed', canvasState.knowledge.list());
58
+ return { ok: true, category };
59
+ },
60
+ }),
61
+ defineOperation({
62
+ name: 'knowledge.category-remove',
63
+ mutates: false,
64
+ input: z.object({ id: z.string().trim().min(1) }),
65
+ inputShape: { id: z.string().trim().min(1) },
66
+ http: { method: 'DELETE', path: '/api/canvas/knowledge/category/:id' },
67
+ mcp: {
68
+ toolName: 'canvas_knowledge_category_remove',
69
+ description:
70
+ 'Delete an unused workspace category by id. Explicit folder assignments, including empty folders, refuse deletion.',
71
+ formatResult: jsonResult,
72
+ },
73
+ handler: (input, ctx) => {
74
+ const id = canvasState.knowledge.removeCategory(input.id);
75
+ ctx.emit('knowledge-changed', canvasState.knowledge.list());
76
+ return { ok: true, id };
77
+ },
78
+ }),
79
+ defineOperation({
80
+ name: 'knowledge.relation-preset-set',
81
+ mutates: false,
82
+ input: z.object({ preset: relationPresetSchema }),
83
+ inputShape: { preset: relationPresetSchema },
84
+ http: { method: 'POST', path: '/api/canvas/knowledge/relation-preset' },
85
+ mcp: {
86
+ toolName: 'canvas_knowledge_relation_preset_set',
87
+ description: 'Save one shared workspace relation preset. Existing boards and connections are unchanged.',
88
+ formatResult: jsonResult,
89
+ },
90
+ handler: (input, ctx) => {
91
+ const preset = canvasState.knowledge.setRelationPreset(input.preset);
92
+ ctx.emit('knowledge-changed', canvasState.knowledge.list());
93
+ return { ok: true, preset };
94
+ },
95
+ }),
96
+ defineOperation({
97
+ name: 'knowledge.relation-preset-remove',
98
+ mutates: false,
99
+ input: z.object({ id: z.string().trim().min(1) }),
100
+ inputShape: { id: z.string().trim().min(1) },
101
+ http: { method: 'DELETE', path: '/api/canvas/knowledge/relation-preset/:id' },
102
+ mcp: {
103
+ toolName: 'canvas_knowledge_relation_preset_remove',
104
+ description: 'Delete a shared relation preset without changing vocabularies already applied to boards.',
105
+ formatResult: jsonResult,
106
+ },
107
+ handler: (input, ctx) => {
108
+ const id = canvasState.knowledge.removeRelationPreset(input.id);
109
+ ctx.emit('knowledge-changed', canvasState.knowledge.list());
110
+ return { ok: true, id };
111
+ },
112
+ }),
113
+ defineOperation({
114
+ name: 'knowledge.folder-set',
115
+ mutates: false,
116
+ input: folderPropertiesSchema,
117
+ inputShape: folderPropertiesSchema.shape,
118
+ http: { method: 'POST', path: '/api/canvas/knowledge/folder' },
119
+ mcp: {
120
+ toolName: 'canvas_knowledge_folder_set',
121
+ description: 'Replace canonical folder properties. Null category inherits; null lifecycle clears the override.',
122
+ formatResult: jsonResult,
123
+ },
124
+ handler: (input, ctx) => {
125
+ try {
126
+ const folder = canvasState.knowledge.setFolder(input);
127
+ ctx.emit('knowledge-changed', canvasState.knowledge.list());
128
+ return { ok: true, folder };
129
+ } catch (error) {
130
+ throw new OperationError(String(error));
131
+ }
132
+ },
133
+ }),
134
+ defineOperation({
135
+ name: 'node.lifecycle-get',
136
+ mutates: false,
137
+ input: z.object({ id: z.string(), board: z.string().optional() }),
138
+ inputShape: { id: z.string(), board: z.string().optional() },
139
+ http: { method: 'GET', path: '/api/canvas/node/:id/lifecycle' },
140
+ mcp: {
141
+ toolName: 'canvas_node_lifecycle_get',
142
+ description:
143
+ 'Read effective category/lifecycle, source folder and audited review history without opening a board.',
144
+ formatResult: jsonResult,
145
+ },
146
+ handler: (input) => {
147
+ const read = readTargetBoard(input.board);
148
+ const node = read.layout.nodes.find((n) => n.id === input.id);
149
+ if (!read.boardId || !node) throw new OperationError('Card not found.', 404);
150
+ const folder = canvasState.listBoards().find((b) => b.id === read.boardId)?.folder ?? null;
151
+ return {
152
+ ok: true,
153
+ boardId: read.boardId,
154
+ nodeId: input.id,
155
+ contentRevision: node.contentRevision ?? 0,
156
+ ...canvasState.knowledge.resolveCard(folder, node),
157
+ ...canvasState.knowledge.reviews(read.boardId, input.id),
158
+ qualification: lifecycleQualifications(Date.now(), [{ boardId: read.boardId, cardId: input.id }])[
159
+ memoryKey({ boardId: read.boardId, cardId: input.id })
160
+ ],
161
+ };
162
+ },
163
+ }),
164
+ defineOperation({
165
+ name: 'node.lifecycle-set',
166
+ mutates: true,
167
+ input: z.object({ ...target, lifecycleOverride: lifecycleSchema.nullable() }),
168
+ inputShape: { ...target, lifecycleOverride: lifecycleSchema.nullable() },
169
+ http: { method: 'POST', path: '/api/canvas/node/:id/lifecycle' },
170
+ mcp: {
171
+ toolName: 'canvas_node_lifecycle_set',
172
+ description:
173
+ 'Set a full card lifecycle override, or null to inherit. Never changes category or semantic content revision.',
174
+ formatResult: jsonResult,
175
+ },
176
+ handler: (input) => {
177
+ canvasState.setNodeLifecycle(input.id, input.lifecycleOverride);
178
+ return { ok: true };
179
+ },
180
+ }),
181
+ defineOperation({
182
+ name: 'node.review',
183
+ mutates: true,
184
+ input: z.object({
185
+ ...target,
186
+ outcome: z.enum(['retain', 'revise', 'externalize', 'retire', 'unresolved']),
187
+ evidence: z.string(),
188
+ applied: z.boolean(),
189
+ expectedContentRevision: z.number().int().nonnegative().optional(),
190
+ expectedSources: z.record(z.string(), z.number().int().nonnegative().nullable()).optional(),
191
+ }),
192
+ inputShape: {
193
+ ...target,
194
+ outcome: z.enum(['retain', 'revise', 'externalize', 'retire', 'unresolved']),
195
+ evidence: z.string(),
196
+ applied: z.boolean(),
197
+ expectedSources: z
198
+ .record(z.string(), z.number().int().nonnegative().nullable())
199
+ .optional()
200
+ .describe('Required when validating dependencies: observations from lifecycle-get after checking the sources.'),
201
+ expectedContentRevision: z
202
+ .number()
203
+ .int()
204
+ .nonnegative()
205
+ .optional()
206
+ .describe('Required for evidence-backed validating outcomes; use the revision of the content you checked.'),
207
+ },
208
+ http: { method: 'POST', path: '/api/canvas/node/:id/review' },
209
+ mcp: {
210
+ toolName: 'canvas_node_review',
211
+ description:
212
+ 'Audit an outcome. Applied retain/revise/externalize with nonempty evidence and matching expectedContentRevision renew validation. Reviewed content is persisted before evidence. Does not edit content or restore retirement.',
213
+ formatResult: jsonResult,
214
+ },
215
+ handler: (input, ctx) => {
216
+ if (!canvasState.getNode(input.id) || !canvasState.activeBoardId)
217
+ throw new OperationError('Card not found.', 404);
218
+ const observations =
219
+ lifecycleQualifications()[memoryKey({ boardId: canvasState.activeBoardId, cardId: input.id })]?.observations ??
220
+ {};
221
+ const validates =
222
+ input.applied && input.evidence.trim() && ['retain', 'revise', 'externalize'].includes(input.outcome);
223
+ if (validates) {
224
+ const expected = input.expectedSources ?? {};
225
+ if (
226
+ Object.keys(expected).length !== Object.keys(observations).length ||
227
+ Object.entries(observations).some(([key, revision]) => revision === null || expected[key] !== revision)
228
+ )
229
+ throw new OperationError(
230
+ 'Sources changed or are unavailable. Check the current sources and pass expectedSources from lifecycle-get.',
231
+ 409,
232
+ );
233
+ }
234
+ const record = canvasState.knowledge.record(canvasState.activeBoardId, input.id, input, observations);
235
+ ctx.emit('lifecycle-changed', { boardId: canvasState.activeBoardId, nodeId: input.id });
236
+ return { ok: true, record };
237
+ },
238
+ }),
239
+ defineOperation({
240
+ name: 'node.lifecycle-restore',
241
+ mutates: true,
242
+ input: z.object({ ...target, evidence: z.string() }),
243
+ inputShape: { ...target, evidence: z.string() },
244
+ http: { method: 'POST', path: '/api/canvas/node/:id/lifecycle/restore' },
245
+ mcp: {
246
+ toolName: 'canvas_node_lifecycle_restore',
247
+ description: 'Explicitly reverse retirement and audit the restoration. Never validates.',
248
+ formatResult: jsonResult,
249
+ },
250
+ handler: (input, ctx) => {
251
+ if (!canvasState.getNode(input.id) || !canvasState.activeBoardId)
252
+ throw new OperationError('Card not found.', 404);
253
+ const record = canvasState.knowledge.record(canvasState.activeBoardId, input.id, {
254
+ outcome: 'restore',
255
+ evidence: input.evidence,
256
+ applied: true,
257
+ });
258
+ ctx.emit('lifecycle-changed', { boardId: canvasState.activeBoardId, nodeId: input.id });
259
+ return { ok: true, record };
260
+ },
261
+ }),
262
+ ];
@@ -0,0 +1,45 @@
1
+ import { z } from 'zod';
2
+ import { memoryGraph, localMemoryGraph, boardMemoryGraph } from '../../memory-graph.js';
3
+ import { defineOperation, OperationError, type Operation } from '../types.js';
4
+
5
+ const shape = {
6
+ projection: z
7
+ .enum(['full', 'boards'])
8
+ .default('full')
9
+ .describe('Full board/card graph, or board-only Map summaries with qualified cross-board and missing relations'),
10
+ focus: z.string().optional().describe('Board ID, board-qualified graph node key, or pins; omit for the library'),
11
+ depth: z
12
+ .union([z.number(), z.string().trim().min(1)])
13
+ .pipe(z.coerce.number<string | number>().int().min(0).max(4))
14
+ .default(2)
15
+ .describe('Undirected hops, including membership, 0–4 (default 2)'),
16
+ };
17
+ const schema = z.looseObject(shape);
18
+ export const memoryGraphOperations: Operation[] = [
19
+ defineOperation({
20
+ name: 'memory-graph.get',
21
+ mutates: false,
22
+ input: schema,
23
+ inputShape: shape,
24
+ http: { method: 'GET', path: '/api/canvas/memory-graph' },
25
+ mcp: {
26
+ toolName: 'canvas_memory_graph',
27
+ description:
28
+ 'Explore the same board/card graph Home draws. Summary-only discovery, not full content reads; writes still target the open board.',
29
+ extraShape: shape,
30
+ formatResult: (result) => ({ content: [{ type: 'text' as const, text: JSON.stringify(result) }] }),
31
+ },
32
+ handler: (input) => {
33
+ const graph = memoryGraph(input.focus ? 'full' : input.projection);
34
+ if (
35
+ input.focus &&
36
+ input.focus !== 'pins' &&
37
+ !graph.nodes.some((node) => node.key === input.focus || (node.kind === 'board' && node.boardId === input.focus))
38
+ ) {
39
+ throw new OperationError('Graph focus not found. Use a board ID, a qualified node key, or pins.', 404);
40
+ }
41
+ const local = localMemoryGraph(graph, input.focus ?? null, input.depth);
42
+ return input.projection === 'boards' ? boardMemoryGraph(local) : local;
43
+ },
44
+ }),
45
+ ];
@@ -8,12 +8,14 @@
8
8
  *
9
9
  * This module must never import server.ts or index.ts.
10
10
  */
11
- import { resolve } from 'node:path';
11
+ import { realpathSync, statSync, writeFileSync } from 'node:fs';
12
+ import { extname, isAbsolute, relative, resolve, sep } from 'node:path';
12
13
  import { z } from 'zod';
13
14
  import { canvasState, type CanvasAnnotation, type CanvasLayout, type CanvasNodeState } from '../../canvas-state.js';
14
15
  import {
15
16
  addCanvasNode,
16
17
  createCanvasGroup,
18
+ storedCreationTarget,
17
19
  buildStructuredNodeUpdate,
18
20
  hasStructuredNodeUpdateFields,
19
21
  hasTraceNodeDataFields,
@@ -53,6 +55,8 @@ import {
53
55
  import { WEBPAGE_NODE_DEFAULT_SIZE, normalizeWebpageUrl } from '../../webpage-node.js';
54
56
  import { defineOperation, OperationError, type Operation, type OperationContext } from '../types.js';
55
57
  import { readTargetBoard } from './boards.js';
58
+ import { lifecycleQualifications } from '../../lifecycle-status.js';
59
+ import { memoryKey } from '../../../shared/memory-graph.js';
56
60
 
57
61
  // ── Node types ────────────────────────────────────────────────
58
62
  // Single definition site for the basic node-type list: drives the HTTP type
@@ -108,6 +112,11 @@ export function defaultNodeSize(type: string): { width: number; height: number }
108
112
 
109
113
  // ── Shared helpers (moved from server.ts) ─────────────────────
110
114
 
115
+ /** Parent traversal in a relative path, using the path implementation's separator. */
116
+ export function isParentTraversal(path: string, separator: string): boolean {
117
+ return path === '..' || path.startsWith(`..${separator}`);
118
+ }
119
+
111
120
  export function isRecord(value: unknown): value is Record<string, unknown> {
112
121
  return value !== null && typeof value === 'object' && !Array.isArray(value);
113
122
  }
@@ -282,6 +291,7 @@ export function compactLayoutPayload(layout: CanvasLayout, pinnedIds: string[]):
282
291
  id: edge.id,
283
292
  from: edge.from,
284
293
  to: edge.to,
294
+ ...(edge.toBoardId !== undefined ? { toBoardId: edge.toBoardId } : {}),
285
295
  type: edge.type,
286
296
  ...(edge.label ? { label: edge.label } : {}),
287
297
  ...(edge.style ? { style: edge.style } : {}),
@@ -325,7 +335,7 @@ export function createdNodePayloadFromNode(
325
335
  */
326
336
  export function createBasicCanvasNode(
327
337
  body: Record<string, unknown>,
328
- options: { fileMode: 'auto' | 'path' },
338
+ options: { fileMode: 'auto' | 'path'; prepareOnlyNodes?: CanvasNodeState[] },
329
339
  ): { node: CanvasNodeState; needsCodeGraphRecompute: boolean; sizeAdjustment?: CanvasSizeAdjustment } {
330
340
  const type = typeof body.type === 'string' ? body.type : '';
331
341
  const extraData = isRecord(body.data) ? body.data : undefined;
@@ -417,6 +427,7 @@ export function createBasicCanvasNode(
417
427
  defaultWidth: defaults.width,
418
428
  defaultHeight: defaults.height,
419
429
  fileMode: options.fileMode,
430
+ prepareOnlyNodes: options.prepareOnlyNodes,
420
431
  });
421
432
  return { node, needsCodeGraphRecompute, ...(sizeAdjustment ? { sizeAdjustment } : {}) };
422
433
  } catch (error) {
@@ -445,6 +456,8 @@ export function buildNodePatch(
445
456
  }
446
457
  if (body.data && typeof body.data === 'object' && !Array.isArray(body.data)) {
447
458
  const data = body.data as Record<string, unknown>;
459
+ if (Object.hasOwn(data, 'lifecycleOverride'))
460
+ throw new OperationError('Update lifecycle overrides through node.lifecycle-set, not a content patch.');
448
461
  const source = data.source;
449
462
  const importedSource = source && typeof source === 'object' && 'attachmentId' in source;
450
463
  const attachedFileContent =
@@ -575,6 +588,30 @@ export function removeNodeCore(id: string): { needsCodeGraphRecompute: boolean }
575
588
 
576
589
  // ── node.add ──────────────────────────────────────────────────
577
590
 
591
+ /** Explicit creation identity is opt-in, not content-based deduplication. */
592
+ export function createDestinationNode(body: Record<string, unknown>): CanvasNodeState {
593
+ const { boardId, requestId, fingerprint } = storedCreationTarget(body, 'node.add');
594
+ const supported = ['markdown', 'status', 'context', 'ledger', 'trace', 'diff', 'mermaid', 'html'];
595
+ if (!supported.includes(String(body.type)) || body.primitive !== undefined || body.kind !== undefined)
596
+ throw new OperationError(
597
+ 'destinationBoardId/requestId support static markdown, status, context, ledger, trace, diff, mermaid and plain html creation only.',
598
+ );
599
+ const data = isRecord(body.data) ? body.data : {};
600
+ if (
601
+ body.intentId !== undefined ||
602
+ ['children', 'childIds', 'childLayout', 'groupId', 'parentId', 'parentGroup'].some(
603
+ (key) => body[key] !== undefined || data[key] !== undefined,
604
+ )
605
+ )
606
+ throw new OperationError('Destination/retry creation does not support ghost intents or membership mutations.');
607
+ return canvasState.createStoredNode(
608
+ boardId,
609
+ requestId,
610
+ fingerprint,
611
+ (nodes) => createBasicCanvasNode(body, { fileMode: 'auto', prepareOnlyNodes: nodes }).node,
612
+ );
613
+ }
614
+
578
615
  interface NodeAddResult {
579
616
  node: CanvasNodeState;
580
617
  extras?: Record<string, unknown>;
@@ -706,6 +743,18 @@ function createGroupNode(body: Record<string, unknown>): NodeAddResult {
706
743
  }
707
744
 
708
745
  const nodeAddShape = {
746
+ destinationBoardId: z
747
+ .string()
748
+ .min(1)
749
+ .optional()
750
+ .describe('Create a static node on this board without opening it. expectedBoardId still guards the ACTIVE board.'),
751
+ requestId: z
752
+ .string()
753
+ .min(1)
754
+ .optional()
755
+ .describe(
756
+ 'Persistent logical creation identity. Reuse only for retries with identical input and destination; new identical content needs a new id. Static types only.',
757
+ ),
709
758
  intentId: z
710
759
  .string()
711
760
  .optional()
@@ -812,25 +861,9 @@ const nodeAddOperation = defineOperation<z.infer<typeof nodeAddSchema>, NodeAddR
812
861
  mcp: {
813
862
  toolName: 'canvas_add_node',
814
863
  description:
815
- 'Add a basic node to the canvas. Returns the created node with normalized title/content and rendered geometry. Supported here: markdown, status, context, ledger, trace, file, diff, mermaid, image, webpage, mcp-app, html, group. Dedicated routes: json-render -> canvas_render {action:"add-json-render"}, graph -> canvas_render {action:"add-graph"}, web-artifact -> canvas_app {action:"build-artifact"}, external apps -> canvas_app {action:"open-mcp-app"}, groups -> canvas_group {action:"create"}. Call canvas_render {action:"describe-schema"} for the full nodeTypeRouting table.',
864
+ 'Add a basic node to the canvas. Returns the created node with normalized title/content and rendered geometry. Supported here: markdown, board (data.boardId is the target), status, context, ledger, trace, file, diff, mermaid, image, webpage, mcp-app, html, group. Dedicated routes: json-render -> canvas_render {action:"add-json-render"}, graph -> canvas_render {action:"add-graph"}, web-artifact -> canvas_app {action:"build-artifact"}, external apps -> canvas_app {action:"open-mcp-app"}, groups -> canvas_group {action:"create"}. Call canvas_render {action:"describe-schema"} for the full nodeTypeRouting table.',
816
865
  extraShape: {
817
- type: z
818
- .enum([
819
- 'markdown',
820
- 'status',
821
- 'context',
822
- 'ledger',
823
- 'trace',
824
- 'file',
825
- 'diff',
826
- 'mermaid',
827
- 'image',
828
- 'webpage',
829
- 'mcp-app',
830
- 'html',
831
- 'group',
832
- ])
833
- .describe('Node type (prefer canvas_group {action:"create"} for groups)'),
866
+ type: z.enum(NODE_TYPES).describe('Node type (prefer canvas_group {action:"create"} for groups)'),
834
867
  children: z
835
868
  .array(z.string())
836
869
  .optional()
@@ -879,6 +912,8 @@ const nodeAddOperation = defineOperation<z.infer<typeof nodeAddSchema>, NodeAddR
879
912
  },
880
913
  handler: async (input, ctx) => {
881
914
  const body: Record<string, unknown> = input;
915
+ if (body.destinationBoardId !== undefined || body.requestId !== undefined)
916
+ return { node: createDestinationNode(body) };
882
917
  const type = typeof body.type === 'string' ? body.type : '';
883
918
  // Report #50: require a resolvable type rather than silently defaulting to a
884
919
  // markdown node — an empty / type-less body created a phantom node before.
@@ -958,23 +993,32 @@ const nodeGetOperation = defineOperation<z.infer<typeof nodeGetSchema>, Serializ
958
993
  },
959
994
  buildInput: (input) => ({ id: input.id, board: input.board, includeBlobs: true }),
960
995
  formatResult: (result, input) => {
961
- const node = result as CanvasNodeState;
962
- const payload = wantsFullPayload(input) ? serializeCanvasNodeForAgent(node) : compactNodePayload(node);
996
+ const node = result as SerializedCanvasNode;
997
+ const payload = {
998
+ ...(wantsFullPayload(input) ? serializeCanvasNodeForAgent(node) : compactNodePayload(node)),
999
+ qualification: node.qualification,
1000
+ };
963
1001
  return { content: [{ type: 'text' as const, text: JSON.stringify(payload, null, 2) }] };
964
1002
  },
965
1003
  },
966
1004
  handler: ({ id, includeBlobs, board }) => {
967
1005
  const full = includeBlobs === true || includeBlobs === 'true';
1006
+ const qualify = (node: CanvasNodeState, boardId: string | null) => ({
1007
+ ...(full ? serializeCanvasNode(node) : serializeCanvasNodeWithBlobSummaries(node)),
1008
+ qualification: boardId
1009
+ ? lifecycleQualifications(Date.now(), [{ boardId, cardId: id }])[memoryKey({ boardId, cardId: id })]
1010
+ : undefined,
1011
+ });
968
1012
  if (typeof board === 'string' && board.trim()) {
969
1013
  const target = readTargetBoard(board, full);
970
1014
  const node = target.layout.nodes.find((candidate) => candidate.id === id);
971
1015
  if (!node) throw new OperationError(`Node "${id}" not found on board "${board}".`, 404);
972
- return full ? serializeCanvasNode(node) : serializeCanvasNodeWithBlobSummaries(node);
1016
+ return qualify(node, target.boardId);
973
1017
  }
974
1018
  const node = full ? canvasState.getNode(id) : canvasState.getNodeForPersistence(id);
975
1019
  if (!node) throw new OperationError(`Node "${id}" not found.`, 404);
976
1020
  const responseNode = withContextPinReadState(node);
977
- return full ? serializeCanvasNode(responseNode) : serializeCanvasNodeWithBlobSummaries(responseNode);
1021
+ return qualify(responseNode, canvasState.activeBoardId);
978
1022
  },
979
1023
  });
980
1024
 
@@ -983,6 +1027,10 @@ const nodeGetOperation = defineOperation<z.infer<typeof nodeGetSchema>, Serializ
983
1027
  const nodeUpdateShape = {
984
1028
  id: z.string().describe('Node ID to update'),
985
1029
  expectedContentRevision: z.number().int().nonnegative().optional().describe('Reject an edit based on stale content'),
1030
+ persistFile: z
1031
+ .boolean()
1032
+ .optional()
1033
+ .describe('Save file-backed Markdown to disk and card together; requires board and content revision preconditions'),
986
1034
  intentId: z
987
1035
  .string()
988
1036
  .optional()
@@ -1175,6 +1223,35 @@ const nodeUpdateOperation = defineOperation<z.infer<typeof nodeUpdateSchema>, Re
1175
1223
  }
1176
1224
  }
1177
1225
 
1226
+ let markdownPath: string | undefined;
1227
+ if (input.persistFile) {
1228
+ if (
1229
+ existing.type !== 'markdown' ||
1230
+ typeof existing.data.path !== 'string' ||
1231
+ !existing.data.path ||
1232
+ typeof input.content !== 'string' ||
1233
+ input.expectedContentRevision === undefined ||
1234
+ typeof input.expectedBoardId !== 'string'
1235
+ ) {
1236
+ throw new OperationError(
1237
+ 'File persistence requires file-backed Markdown, content, expectedBoardId and expectedContentRevision.',
1238
+ );
1239
+ }
1240
+ const data = isRecord(body.data) ? body.data : {};
1241
+ if (Object.keys(data).some((key) => key !== 'rendered'))
1242
+ throw new OperationError('File persistence accepts only data.rendered; update metadata separately.');
1243
+ markdownPath = realpathSync(resolve(canvasState.getWorkspaceRoot(), existing.data.path));
1244
+ const workspacePath = relative(realpathSync(canvasState.getWorkspaceRoot()), markdownPath);
1245
+ if (
1246
+ isParentTraversal(workspacePath, sep) ||
1247
+ isAbsolute(workspacePath) ||
1248
+ extname(markdownPath).toLowerCase() !== '.md' ||
1249
+ !statSync(markdownPath).isFile()
1250
+ ) {
1251
+ throw new OperationError('File persistence requires a Markdown file inside the workspace.');
1252
+ }
1253
+ body = { ...body, content: input.content.replace(/\r\n?/g, '\n') };
1254
+ }
1178
1255
  const { patch, groupChildIds } = buildNodePatch(existing, body);
1179
1256
  const prevPath = existing.type === 'file' && typeof existing.data.path === 'string' ? existing.data.path : null;
1180
1257
  if (nextFilePath && nextFileContent && patch.data) {
@@ -1186,6 +1263,12 @@ const nodeUpdateOperation = defineOperation<z.infer<typeof nodeUpdateSchema>, Re
1186
1263
  applyFileContentToNodeData(data, nextFileContent);
1187
1264
  patch.data = data;
1188
1265
  }
1266
+ if (markdownPath && patch.data) {
1267
+ // No await between revision validation, disk persistence and the card
1268
+ // update: a refused/stale attempt must not write either half.
1269
+ writeFileSync(markdownPath, String(patch.data.content), 'utf8');
1270
+ patch.data.savedAt = new Date().toISOString();
1271
+ }
1189
1272
  canvasState.updateNode(id, patch);
1190
1273
  if (groupChildIds !== undefined && !setGroupChildrenFromApi(id, groupChildIds)) {
1191
1274
  throw new OperationError(`Group "${id}" not found.`, 404);