@cortexkit/opencode-magic-context 0.33.1 → 0.34.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 (240) hide show
  1. package/README.md +20 -3
  2. package/dist/config/agent-disable.d.ts +22 -0
  3. package/dist/config/agent-disable.d.ts.map +1 -1
  4. package/dist/config/index.d.ts.map +1 -1
  5. package/dist/config/migrate-experimental.d.ts +2 -1
  6. package/dist/config/migrate-experimental.d.ts.map +1 -1
  7. package/dist/config/project-security.d.ts +9 -0
  8. package/dist/config/project-security.d.ts.map +1 -1
  9. package/dist/config/schema/magic-context.d.ts +90 -17
  10. package/dist/config/schema/magic-context.d.ts.map +1 -1
  11. package/dist/config/transform-mode.d.ts +2 -0
  12. package/dist/config/transform-mode.d.ts.map +1 -1
  13. package/dist/{conflict-warning-hook-n48c783v.js → conflict-warning-hook-fvpfvt0w.js} +1 -1
  14. package/dist/{derive-budgets-e9j09sjr.js → derive-budgets-9crp0cvd.js} +1 -1
  15. package/dist/features/builtin-commands/commands.d.ts +1 -1
  16. package/dist/features/builtin-commands/commands.d.ts.map +1 -1
  17. package/dist/features/magic-context/compaction-marker.d.ts +57 -0
  18. package/dist/features/magic-context/compaction-marker.d.ts.map +1 -1
  19. package/dist/features/magic-context/compartment-chunk-embedding.d.ts.map +1 -1
  20. package/dist/features/magic-context/dreamer/classify.d.ts +1 -0
  21. package/dist/features/magic-context/dreamer/classify.d.ts.map +1 -1
  22. package/dist/features/magic-context/dreamer/evaluate-smart-notes.d.ts.map +1 -1
  23. package/dist/features/magic-context/dreamer/map-memories.d.ts +1 -0
  24. package/dist/features/magic-context/dreamer/map-memories.d.ts.map +1 -1
  25. package/dist/features/magic-context/dreamer/refresh-primers.d.ts +1 -0
  26. package/dist/features/magic-context/dreamer/refresh-primers.d.ts.map +1 -1
  27. package/dist/features/magic-context/dreamer/storage-dream-runs.d.ts +2 -0
  28. package/dist/features/magic-context/dreamer/storage-dream-runs.d.ts.map +1 -1
  29. package/dist/features/magic-context/dreamer/task-executor.d.ts +4 -1
  30. package/dist/features/magic-context/dreamer/task-executor.d.ts.map +1 -1
  31. package/dist/features/magic-context/dreamer/task-gates.d.ts +19 -1
  32. package/dist/features/magic-context/dreamer/task-gates.d.ts.map +1 -1
  33. package/dist/features/magic-context/dreamer/task-registry.d.ts +28 -0
  34. package/dist/features/magic-context/dreamer/task-registry.d.ts.map +1 -1
  35. package/dist/features/magic-context/dreamer/task-scheduler.d.ts +7 -1
  36. package/dist/features/magic-context/dreamer/task-scheduler.d.ts.map +1 -1
  37. package/dist/features/magic-context/dreamer/verify.d.ts +1 -0
  38. package/dist/features/magic-context/dreamer/verify.d.ts.map +1 -1
  39. package/dist/features/magic-context/fail-closed-block.d.ts +10 -0
  40. package/dist/features/magic-context/fail-closed-block.d.ts.map +1 -1
  41. package/dist/features/magic-context/git-commits/storage-git-commit-embeddings.d.ts.map +1 -1
  42. package/dist/features/magic-context/git-commits/storage-git-commits.d.ts.map +1 -1
  43. package/dist/features/magic-context/memory/embedding-identity.d.ts.map +1 -1
  44. package/dist/features/magic-context/memory/embedding-local.d.ts +7 -1
  45. package/dist/features/magic-context/memory/embedding-local.d.ts.map +1 -1
  46. package/dist/features/magic-context/memory/embedding.d.ts.map +1 -1
  47. package/dist/features/magic-context/memory/memory-migration.d.ts.map +1 -1
  48. package/dist/features/magic-context/memory/project-identity.d.ts +2 -6
  49. package/dist/features/magic-context/memory/project-identity.d.ts.map +1 -1
  50. package/dist/features/magic-context/memory/storage-memory-embeddings.d.ts.map +1 -1
  51. package/dist/features/magic-context/migrations.d.ts +23 -1
  52. package/dist/features/magic-context/migrations.d.ts.map +1 -1
  53. package/dist/features/magic-context/mural/compress-cues.d.ts +1 -0
  54. package/dist/features/magic-context/mural/compress-cues.d.ts.map +1 -1
  55. package/dist/features/magic-context/mural/render-mural.d.ts.map +1 -1
  56. package/dist/features/magic-context/overflow-detection.d.ts +8 -1
  57. package/dist/features/magic-context/overflow-detection.d.ts.map +1 -1
  58. package/dist/features/magic-context/project-embedding-registry.d.ts.map +1 -1
  59. package/dist/features/magic-context/scheduler.d.ts.map +1 -1
  60. package/dist/features/magic-context/schema-fence-probe.d.ts +28 -0
  61. package/dist/features/magic-context/schema-fence-probe.d.ts.map +1 -0
  62. package/dist/features/magic-context/sidekick/agent.d.ts.map +1 -1
  63. package/dist/features/magic-context/smart-notes/compiler.d.ts.map +1 -1
  64. package/dist/features/magic-context/storage-db.d.ts +37 -2
  65. package/dist/features/magic-context/storage-db.d.ts.map +1 -1
  66. package/dist/features/magic-context/storage-meta-persisted.d.ts +38 -4
  67. package/dist/features/magic-context/storage-meta-persisted.d.ts.map +1 -1
  68. package/dist/features/magic-context/storage-meta.d.ts +1 -1
  69. package/dist/features/magic-context/storage-meta.d.ts.map +1 -1
  70. package/dist/features/magic-context/storage.d.ts +2 -2
  71. package/dist/features/magic-context/storage.d.ts.map +1 -1
  72. package/dist/features/magic-context/user-memory/review-user-memories.d.ts.map +1 -1
  73. package/dist/hooks/magic-context/boundary-execution.d.ts +1 -1
  74. package/dist/hooks/magic-context/boundary-execution.d.ts.map +1 -1
  75. package/dist/hooks/magic-context/cache-busting-signals.d.ts +1 -0
  76. package/dist/hooks/magic-context/cache-busting-signals.d.ts.map +1 -1
  77. package/dist/hooks/magic-context/child-session-spawn.d.ts +26 -0
  78. package/dist/hooks/magic-context/child-session-spawn.d.ts.map +1 -0
  79. package/dist/hooks/magic-context/command-handler.d.ts +3 -0
  80. package/dist/hooks/magic-context/command-handler.d.ts.map +1 -1
  81. package/dist/hooks/magic-context/compaction-off-transition.d.ts +96 -0
  82. package/dist/hooks/magic-context/compaction-off-transition.d.ts.map +1 -0
  83. package/dist/hooks/magic-context/compartment-runner-historian.d.ts +2 -0
  84. package/dist/hooks/magic-context/compartment-runner-historian.d.ts.map +1 -1
  85. package/dist/hooks/magic-context/compartment-runner-incremental.d.ts.map +1 -1
  86. package/dist/hooks/magic-context/compartment-runner-mapping.d.ts.map +1 -1
  87. package/dist/hooks/magic-context/compartment-runner-partial-recomp.d.ts.map +1 -1
  88. package/dist/hooks/magic-context/compartment-runner-recomp.d.ts.map +1 -1
  89. package/dist/hooks/magic-context/compartment-trigger.d.ts +12 -5
  90. package/dist/hooks/magic-context/compartment-trigger.d.ts.map +1 -1
  91. package/dist/hooks/magic-context/ctx-reduce-availability.d.ts +47 -1
  92. package/dist/hooks/magic-context/ctx-reduce-availability.d.ts.map +1 -1
  93. package/dist/hooks/magic-context/emergency-drop.d.ts +2 -2
  94. package/dist/hooks/magic-context/event-handler.d.ts +7 -0
  95. package/dist/hooks/magic-context/event-handler.d.ts.map +1 -1
  96. package/dist/hooks/magic-context/event-resolvers.d.ts +13 -21
  97. package/dist/hooks/magic-context/event-resolvers.d.ts.map +1 -1
  98. package/dist/hooks/magic-context/execute-status.d.ts +5 -1
  99. package/dist/hooks/magic-context/execute-status.d.ts.map +1 -1
  100. package/dist/hooks/magic-context/final-wire-token-estimate.d.ts +2 -0
  101. package/dist/hooks/magic-context/final-wire-token-estimate.d.ts.map +1 -1
  102. package/dist/hooks/magic-context/heuristic-cleanup.d.ts +1 -1
  103. package/dist/hooks/magic-context/hook-handlers.d.ts +1 -0
  104. package/dist/hooks/magic-context/hook-handlers.d.ts.map +1 -1
  105. package/dist/hooks/magic-context/hook.d.ts +14 -6
  106. package/dist/hooks/magic-context/hook.d.ts.map +1 -1
  107. package/dist/hooks/magic-context/inject-compartments.d.ts +10 -1
  108. package/dist/hooks/magic-context/inject-compartments.d.ts.map +1 -1
  109. package/dist/hooks/magic-context/live-session-state.d.ts +3 -0
  110. package/dist/hooks/magic-context/live-session-state.d.ts.map +1 -1
  111. package/dist/hooks/magic-context/protected-tail-boundary.d.ts.map +1 -1
  112. package/dist/hooks/magic-context/read-session-db.d.ts.map +1 -1
  113. package/dist/hooks/magic-context/read-session-true-raw-tokens.d.ts.map +1 -1
  114. package/dist/hooks/magic-context/rust-mode-transform.d.ts +3 -0
  115. package/dist/hooks/magic-context/rust-mode-transform.d.ts.map +1 -1
  116. package/dist/hooks/magic-context/send-session-notification.d.ts +20 -1
  117. package/dist/hooks/magic-context/send-session-notification.d.ts.map +1 -1
  118. package/dist/hooks/magic-context/transform-compartment-phase.d.ts +3 -0
  119. package/dist/hooks/magic-context/transform-compartment-phase.d.ts.map +1 -1
  120. package/dist/hooks/magic-context/transform-postprocess-phase.d.ts +31 -1
  121. package/dist/hooks/magic-context/transform-postprocess-phase.d.ts.map +1 -1
  122. package/dist/hooks/magic-context/transform.d.ts +29 -2
  123. package/dist/hooks/magic-context/transform.d.ts.map +1 -1
  124. package/dist/{index-56yjaeb2.js → index-2y0ybv5x.js} +11 -1
  125. package/dist/{index-ey1bc14z.js → index-b563sga5.js} +7 -264
  126. package/dist/{index-y1c9tcm3.js → index-cn960ffd.js} +101 -20
  127. package/dist/{index-xyaekybw.js → index-dtas91s5.js} +6 -4
  128. package/dist/index-fcyvznch.js +279 -0
  129. package/dist/{index-63dq2g4d.js → index-ry8rh8cv.js} +138 -24
  130. package/dist/{index-c2gahrks.js → index-x8spt5wy.js} +1682 -893
  131. package/dist/index.d.ts.map +1 -1
  132. package/dist/index.js +1677 -723
  133. package/dist/plugin/dream-timer.d.ts +4 -2
  134. package/dist/plugin/dream-timer.d.ts.map +1 -1
  135. package/dist/plugin/embedding-bootstrap.d.ts.map +1 -1
  136. package/dist/plugin/hooks/create-session-hooks.d.ts +12 -1
  137. package/dist/plugin/hooks/create-session-hooks.d.ts.map +1 -1
  138. package/dist/plugin/messages-transform.d.ts +10 -0
  139. package/dist/plugin/messages-transform.d.ts.map +1 -1
  140. package/dist/plugin/rpc-handlers.d.ts +3 -7
  141. package/dist/plugin/rpc-handlers.d.ts.map +1 -1
  142. package/dist/plugin/tool-registry.d.ts +8 -0
  143. package/dist/plugin/tool-registry.d.ts.map +1 -1
  144. package/dist/{recomp-orchestrator-2yb2hfh2.js → recomp-orchestrator-k72ejkkh.js} +6 -4
  145. package/dist/send-session-notification-6njqewzk.js +20 -0
  146. package/dist/shared/conflict-detector.d.ts +38 -1
  147. package/dist/shared/conflict-detector.d.ts.map +1 -1
  148. package/dist/shared/conflict-fixer.d.ts +18 -1
  149. package/dist/shared/conflict-fixer.d.ts.map +1 -1
  150. package/dist/shared/context-limit-provenance.d.ts +3 -0
  151. package/dist/shared/context-limit-provenance.d.ts.map +1 -0
  152. package/dist/shared/escalation-bands.d.ts +9 -0
  153. package/dist/shared/escalation-bands.d.ts.map +1 -0
  154. package/dist/shared/format-threshold.d.ts +1 -1
  155. package/dist/shared/models-dev-cache.d.ts +29 -3
  156. package/dist/shared/models-dev-cache.d.ts.map +1 -1
  157. package/dist/shared/rpc-server.d.ts.map +1 -1
  158. package/dist/shared/rpc-types.d.ts +51 -1
  159. package/dist/shared/rpc-types.d.ts.map +1 -1
  160. package/dist/shared/rpc-utils.d.ts +19 -0
  161. package/dist/shared/rpc-utils.d.ts.map +1 -1
  162. package/dist/shared/storage-permissions.d.ts +5 -0
  163. package/dist/shared/storage-permissions.d.ts.map +1 -0
  164. package/dist/shared/tui-runtime-specifiers.d.ts +20 -0
  165. package/dist/shared/tui-runtime-specifiers.d.ts.map +1 -0
  166. package/dist/{storage-meta-session-td6bwtky.js → storage-meta-session-xt41zbw4.js} +2 -1
  167. package/dist/{task-config-5nx3qfz8.js → task-config-gbp7r23g.js} +1 -1
  168. package/dist/tui/compaction-off.d.ts +13 -0
  169. package/dist/tui/compaction-off.d.ts.map +1 -0
  170. package/dist/tui/data/context-db.d.ts.map +1 -1
  171. package/dist/tui-compiled/compaction-off.d.ts +13 -0
  172. package/dist/tui-compiled/compaction-off.d.ts.map +1 -0
  173. package/dist/tui-compiled/data/context-db.d.ts.map +1 -1
  174. package/package.json +6 -1
  175. package/src/agents/dreamer.ts +68 -0
  176. package/src/agents/hidden-agent-registrations.ts +331 -0
  177. package/src/agents/historian.ts +3 -0
  178. package/src/agents/language-directive.test.ts +138 -0
  179. package/src/agents/language-directive.ts +121 -0
  180. package/src/agents/magic-context-prompt.language.test.ts +35 -0
  181. package/src/agents/magic-context-prompt.test.ts +292 -0
  182. package/src/agents/magic-context-prompt.ts +182 -0
  183. package/src/agents/permissions.test.ts +252 -0
  184. package/src/agents/permissions.ts +186 -0
  185. package/src/agents/sidekick.ts +1 -0
  186. package/src/agents/smart-note-compiler.ts +1 -0
  187. package/src/config/agent-disable.ts +106 -0
  188. package/src/config/compaction-accessor-guard.test.ts +82 -0
  189. package/src/config/compaction-backcompat.test.ts +52 -0
  190. package/src/config/index.test.ts +936 -0
  191. package/src/config/index.ts +601 -0
  192. package/src/config/migrate-config-location.test.ts +332 -0
  193. package/src/config/migrate-config-location.ts +635 -0
  194. package/src/config/migrate-dreamer-v2.test.ts +287 -0
  195. package/src/config/migrate-dreamer-v2.ts +336 -0
  196. package/src/config/migrate-experimental.test.ts +41 -0
  197. package/src/config/migrate-experimental.ts +185 -0
  198. package/src/config/project-security.test.ts +388 -0
  199. package/src/config/project-security.ts +611 -0
  200. package/src/config/prune-config-leaf.test.ts +78 -0
  201. package/src/config/prune-config-leaf.ts +68 -0
  202. package/src/config/removed-config-keys.test.ts +116 -0
  203. package/src/config/schema/agent-overrides.ts +45 -0
  204. package/src/config/schema/magic-context.test.ts +359 -0
  205. package/src/config/schema/magic-context.ts +991 -0
  206. package/src/config/transform-mode.test.ts +62 -0
  207. package/src/config/transform-mode.ts +34 -0
  208. package/src/config/variable.test.ts +323 -0
  209. package/src/config/variable.ts +192 -0
  210. package/src/features/magic-context/defaults.ts +1 -0
  211. package/src/features/magic-context/dreamer/cron.ts +243 -0
  212. package/src/features/magic-context/dreamer/task-registry.ts +151 -0
  213. package/src/shared/conflict-detector.test.ts +121 -0
  214. package/src/shared/conflict-detector.ts +50 -3
  215. package/src/shared/conflict-fixer.test.ts +309 -0
  216. package/src/shared/conflict-fixer.ts +82 -5
  217. package/src/shared/context-limit-provenance.ts +6 -0
  218. package/src/shared/escalation-bands.test.ts +23 -0
  219. package/src/shared/escalation-bands.ts +18 -0
  220. package/src/shared/format-threshold.ts +1 -1
  221. package/src/shared/models-dev-cache.test.ts +171 -0
  222. package/src/shared/models-dev-cache.ts +195 -38
  223. package/src/shared/rpc-server.ts +27 -18
  224. package/src/shared/rpc-types.ts +55 -1
  225. package/src/shared/rpc-utils.test.ts +157 -0
  226. package/src/shared/rpc-utils.ts +137 -0
  227. package/src/shared/storage-permissions.ts +20 -0
  228. package/src/shared/tui-runtime-specifiers.ts +32 -0
  229. package/src/tui/compaction-off.test.ts +56 -0
  230. package/src/tui/compaction-off.ts +29 -0
  231. package/src/tui/data/context-db.ts +6 -0
  232. package/src/tui/index.tsx +113 -65
  233. package/src/tui/slots/sidebar-content.tsx +189 -91
  234. package/src/tui/tui-compiled-runtime-imports.test.ts +151 -0
  235. package/src/tui-compiled/compaction-off.ts +29 -0
  236. package/src/tui-compiled/data/context-db.ts +6 -0
  237. package/src/tui-compiled/index.tsx +558 -455
  238. package/src/tui-compiled/slots/sidebar-content.tsx +334 -169
  239. package/dist/send-session-notification-tghq9gnh.js +0 -11
  240. /package/dist/{index-v7fmc1s2.js → index-ygcng9t8.js} +0 -0
@@ -0,0 +1,287 @@
1
+ /// <reference types="bun-types" />
2
+
3
+ import { describe, expect, it } from "bun:test";
4
+ import { migrateDreamerV2 } from "./migrate-dreamer-v2";
5
+
6
+ function migrate(raw: Record<string, unknown>): {
7
+ out: Record<string, unknown>;
8
+ warnings: string[];
9
+ } {
10
+ const warnings: string[] = [];
11
+ const out = migrateDreamerV2(raw, warnings);
12
+ return { out, warnings };
13
+ }
14
+
15
+ function tasks(
16
+ out: Record<string, unknown>,
17
+ ): Record<string, { schedule?: string } & Record<string, unknown>> {
18
+ return (out.dreamer as Record<string, unknown>).tasks as Record<
19
+ string,
20
+ { schedule?: string } & Record<string, unknown>
21
+ >;
22
+ }
23
+
24
+ describe("migrateDreamerV2", () => {
25
+ it("is a no-op when there is no dreamer block", () => {
26
+ const { out } = migrate({ enabled: true });
27
+ expect(out).toEqual({ enabled: true });
28
+ });
29
+
30
+ it("a v2 record MISSING verify-broad gets it backfilled (coupled to verify)", () => {
31
+ // A v2 tasks-object predating the verify-broad split must gain the task
32
+ // rather than silently inheriting Zod's default-on — otherwise a user who
33
+ // disabled verify would get unintended weekly broad verification.
34
+ const v2 = { dreamer: { tasks: { verify: { schedule: "0 3 * * *" } } } };
35
+ const { out, warnings } = migrate(structuredClone(v2));
36
+ expect(tasks(out)["verify-broad"].schedule).toBe("0 4 * * 0");
37
+ expect(warnings).toHaveLength(0);
38
+ });
39
+
40
+ it("is a no-op when dreamer has only non-legacy keys (e.g. model)", () => {
41
+ const { out, warnings } = migrate({ dreamer: { model: "x/y" } });
42
+ expect(out).toEqual({ dreamer: { model: "x/y" } });
43
+ expect(warnings).toHaveLength(0);
44
+ });
45
+
46
+ it("derives the base cron from the window START", () => {
47
+ const { out } = migrate({ dreamer: { schedule: "02:00-06:00", tasks: ["consolidate"] } });
48
+ expect(tasks(out).curate.schedule).toBe("0 2 * * *");
49
+ expect(tasks(out).verify.schedule).toBe("");
50
+ });
51
+
52
+ it("listed tasks get the base cron; OMITTED canonical tasks are DISABLED", () => {
53
+ // Deliberate selection: only consolidate + verify. Legacy canonical tasks are disabled;
54
+ // the new classify task defaults on daily because it did not exist in v1.
55
+ const { out } = migrate({
56
+ dreamer: { schedule: "03:30-06:00", tasks: ["consolidate", "verify"] },
57
+ });
58
+ const t = tasks(out);
59
+ expect(t.verify.schedule).toBe("30 3 * * *");
60
+ expect(t.curate.schedule).toBe("30 3 * * *");
61
+ expect(t["classify-memories"].schedule).toBe("0 6 * * *");
62
+ expect(t["maintain-docs"].schedule).toBe(""); // omitted → disabled
63
+ });
64
+
65
+ it("when tasks array is ABSENT, preserves the v1 default suite (maintain-docs off)", () => {
66
+ const { out } = migrate({ dreamer: { schedule: "02:00-06:00" } });
67
+ const t = tasks(out);
68
+ expect(t.verify.schedule).toBe("0 2 * * *");
69
+ // verify enabled → verify-broad defaults ON weekly; broad_interval_days gone.
70
+ expect(t["verify-broad"].schedule).toBe("0 4 * * 0");
71
+ expect(t.verify.broad_interval_days).toBeUndefined();
72
+ expect(t.curate.schedule).toBe("0 2 * * *");
73
+ expect(t["classify-memories"].schedule).toBe("0 6 * * *");
74
+ expect(t["maintain-docs"].schedule).toBe(""); // not in v1 default list
75
+ });
76
+
77
+ it("user_memories.enabled false → review-user-memories disabled (opt-out preserved)", () => {
78
+ const { out } = migrate({
79
+ dreamer: { schedule: "02:00-06:00", user_memories: { enabled: false } },
80
+ });
81
+ expect(tasks(out)["review-user-memories"].schedule).toBe("");
82
+ });
83
+
84
+ it("user_memories default (no block) → review-user-memories enabled", () => {
85
+ const { out } = migrate({ dreamer: { schedule: "02:00-06:00" } });
86
+ const rum = tasks(out)["review-user-memories"];
87
+ expect(rum.schedule).toBe("0 2 * * *");
88
+ });
89
+
90
+ it("carries user_memories.promotion_threshold", () => {
91
+ const { out } = migrate({
92
+ dreamer: {
93
+ schedule: "02:00-06:00",
94
+ user_memories: { enabled: true, promotion_threshold: 7 },
95
+ },
96
+ });
97
+ expect(tasks(out)["review-user-memories"].promotion_threshold).toBe(7);
98
+ });
99
+
100
+ it("legacy pin_key_files block is dropped — no key-files task is emitted", () => {
101
+ const { out } = migrate({
102
+ dreamer: {
103
+ schedule: "02:00-06:00",
104
+ pin_key_files: { enabled: true, token_budget: 8000, min_reads: 6 },
105
+ },
106
+ });
107
+ // key-files was removed (feature moved to AFT's dreamer).
108
+ expect("key-files" in tasks(out)).toBe(false);
109
+ expect("pin_key_files" in (out.dreamer as Record<string, unknown>)).toBe(false);
110
+ });
111
+
112
+ it("evaluate-smart-notes always gets the base cron", () => {
113
+ const { out } = migrate({ dreamer: { schedule: "02:00-06:00", tasks: [] } });
114
+ expect(tasks(out)["evaluate-smart-notes"].schedule).toBe("0 2 * * *");
115
+ });
116
+
117
+ it("new classify-memories and retrospective tasks default on daily unless dreamer is disabled", () => {
118
+ const { out } = migrate({ dreamer: { schedule: "02:00-06:00", tasks: [] } });
119
+ expect(tasks(out)["classify-memories"].schedule).toBe("0 6 * * *");
120
+ expect(tasks(out).retrospective.schedule).toBe("0 5 * * *");
121
+
122
+ const disabled = migrate({ dreamer: { disable: true, schedule: "02:00-06:00" } });
123
+ expect(tasks(disabled.out)["classify-memories"].schedule).toBe("");
124
+ expect(tasks(disabled.out).retrospective.schedule).toBe("");
125
+ });
126
+
127
+ it("task_timeout_minutes becomes each task's timeout_minutes", () => {
128
+ const { out } = migrate({
129
+ dreamer: { schedule: "02:00-06:00", task_timeout_minutes: 15 },
130
+ });
131
+ const t = tasks(out);
132
+ expect(t.verify.timeout_minutes).toBe(15);
133
+ expect(t.curate.timeout_minutes).toBe(15);
134
+ expect(t["review-user-memories"].timeout_minutes).toBe(15);
135
+ });
136
+
137
+ it("drops retired keys and preserves agent-config keys (model, disable)", () => {
138
+ const { out } = migrate({
139
+ dreamer: {
140
+ schedule: "02:00-06:00",
141
+ max_runtime_minutes: 120,
142
+ model: "anthropic/x",
143
+ disable: false,
144
+ },
145
+ });
146
+ const d = out.dreamer as Record<string, unknown>;
147
+ expect(d.model).toBe("anthropic/x");
148
+ expect(d.disable).toBe(false);
149
+ expect("schedule" in d).toBe(false);
150
+ expect("max_runtime_minutes" in d).toBe(false);
151
+ expect("user_memories" in d).toBe(false);
152
+ expect("pin_key_files" in d).toBe(false);
153
+ });
154
+
155
+ it("falls back to the default base cron on an unparseable window", () => {
156
+ const { out } = migrate({ dreamer: { schedule: "garbage", tasks: ["consolidate"] } });
157
+ expect(tasks(out).curate.schedule).toBe("0 2 * * *");
158
+ });
159
+
160
+ it("emits a migration warning", () => {
161
+ const { warnings } = migrate({ dreamer: { schedule: "02:00-06:00" } });
162
+ expect(warnings.join("\n")).toContain("dreamer.tasks");
163
+ });
164
+
165
+ it("all canonical tasks are present after migration (no key-files)", () => {
166
+ const { out } = migrate({ dreamer: { schedule: "02:00-06:00" } });
167
+ expect(Object.keys(tasks(out)).sort()).toEqual(
168
+ [
169
+ "map-memories",
170
+ "classify-memories",
171
+ "curate",
172
+ "evaluate-smart-notes",
173
+ "maintain-docs",
174
+ "retrospective",
175
+ "review-user-memories",
176
+ "verify",
177
+ "verify-broad",
178
+ ].sort(),
179
+ );
180
+ });
181
+
182
+ it("strips a stale key-files task from an existing v2 tasks-object", () => {
183
+ const { out } = migrate({
184
+ dreamer: {
185
+ tasks: {
186
+ verify: { schedule: "0 3 * * *" },
187
+ "verify-broad": { schedule: "0 4 * * 0" },
188
+ "key-files": { schedule: "0 2 * * *" },
189
+ },
190
+ },
191
+ });
192
+ expect("key-files" in tasks(out)).toBe(false);
193
+ expect(tasks(out).verify.schedule).toBe("0 3 * * *");
194
+ });
195
+
196
+ it("folds object-shaped retired memory tasks into verify + curate", () => {
197
+ const { out } = migrate({
198
+ dreamer: {
199
+ tasks: {
200
+ verify: { schedule: "0 3 * * *" },
201
+ improve: { schedule: "0 * * * *" },
202
+ "maintain-docs": { schedule: "0 4 * * *" },
203
+ },
204
+ },
205
+ });
206
+ const t = tasks(out);
207
+ expect(t.verify.schedule).toBe("0 3 * * *");
208
+ // verify present+enabled → verify-broad defaults ON weekly.
209
+ expect(t["verify-broad"].schedule).toBe("0 4 * * 0");
210
+ expect(t.verify.broad_interval_days).toBeUndefined();
211
+ expect(t.curate.schedule).toBe("0 * * * *");
212
+ expect(t["classify-memories"].schedule).toBe("0 6 * * *");
213
+ expect(t.retrospective.schedule).toBe("0 5 * * *");
214
+ expect(t["maintain-docs"].schedule).toBe("0 4 * * *");
215
+ expect(t.improve).toBeUndefined();
216
+ });
217
+
218
+ it("v2-object with verify DISABLED backfills verify-broad OFF (not Zod's default-on)", () => {
219
+ const { out } = migrate({
220
+ dreamer: {
221
+ tasks: {
222
+ verify: { schedule: "" },
223
+ curate: { schedule: "0 4 * * 0" },
224
+ },
225
+ },
226
+ });
227
+ const t = tasks(out);
228
+ expect(t.verify.schedule).toBe("");
229
+ // verify off → verify-broad must be off, NOT the schema default 0 4 * * 0.
230
+ expect(t["verify-broad"].schedule).toBe("");
231
+ });
232
+
233
+ it("v2-object with verify ENABLED backfills verify-broad ON weekly", () => {
234
+ const { out } = migrate({
235
+ dreamer: { tasks: { verify: { schedule: "0 3 * * *" } } },
236
+ });
237
+ expect(tasks(out)["verify-broad"].schedule).toBe("0 4 * * 0");
238
+ });
239
+
240
+ it("v2-object strips a stale broad_interval_days from verify", () => {
241
+ const { out } = migrate({
242
+ dreamer: {
243
+ tasks: { verify: { schedule: "0 3 * * *", broad_interval_days: 9 } },
244
+ },
245
+ });
246
+ const t = tasks(out);
247
+ expect(t.verify.broad_interval_days).toBeUndefined();
248
+ expect(t["verify-broad"].schedule).toBe("0 4 * * 0");
249
+ });
250
+
251
+ it("v2-object already reconciled is a stable no-op (idempotent)", () => {
252
+ const input = {
253
+ dreamer: {
254
+ tasks: {
255
+ verify: { schedule: "0 3 * * *" },
256
+ "verify-broad": { schedule: "0 4 * * 0" },
257
+ },
258
+ },
259
+ };
260
+ const { out } = migrate(input);
261
+ // Unchanged → returns the SAME object reference (no churn on every load).
262
+ expect(out).toBe(input);
263
+ });
264
+
265
+ it("maps object-shaped maintain-memory to verify + curate, broad_interval_days dropped", () => {
266
+ const { out } = migrate({
267
+ dreamer: {
268
+ tasks: {
269
+ "maintain-memory": {
270
+ schedule: "0 5 * * *",
271
+ model: "x/y",
272
+ broad_interval_days: 9,
273
+ },
274
+ },
275
+ },
276
+ });
277
+ const t = tasks(out);
278
+ expect(t.verify.schedule).toBe("0 5 * * *");
279
+ expect(t.verify.model).toBe("x/y");
280
+ // The legacy broad_interval_days knob is dropped; broad is its own task.
281
+ expect(t.verify.broad_interval_days).toBeUndefined();
282
+ expect(t["verify-broad"].schedule).toBe("0 4 * * 0");
283
+ expect(t.curate.schedule).toBe("0 5 * * *");
284
+ expect(t.curate.model).toBe("x/y");
285
+ expect(t["maintain-memory"]).toBeUndefined();
286
+ });
287
+ });
@@ -0,0 +1,336 @@
1
+ /**
2
+ * In-memory migration of the legacy v1 dreamer config shape to the Dreamer v2
3
+ * per-task shape (shared OpenCode + Pi; runs on every config load, like
4
+ * migrate-experimental). Doctor performs the on-disk equivalent.
5
+ *
6
+ * v1 shape (any subset):
7
+ * dreamer: {
8
+ * schedule: "02:00-06:00", // a TIME WINDOW
9
+ * tasks: ["consolidate","verify"], // an ARRAY of agentic task names
10
+ * task_timeout_minutes: 20,
11
+ * max_runtime_minutes: 120,
12
+ * user_memories: { enabled, promotion_threshold },
13
+ * pin_key_files: { enabled, token_budget, min_reads },
14
+ * }
15
+ *
16
+ * v2 shape:
17
+ * dreamer: {
18
+ * tasks: { <task>: { schedule: "<cron>"|"", model?, timeout_minutes?, ... } }
19
+ * }
20
+ *
21
+ * Rules (see dreamer-v2-AB-spec.md):
22
+ * - Base cron derived from the WINDOW START: "02:00-06:00" → "0 2 * * *".
23
+ * - Legacy `tasks` array PRESENT → it is the user's deliberate selection: each
24
+ * LISTED agentic task gets the base cron; each OMITTED canonical task gets ""
25
+ * (disabled). Built-in defaults are used ONLY when `tasks` is absent.
26
+ * - user_memories.enabled false → review-user-memories "" ; true → base cron
27
+ * (promotion_threshold carried). A legacy pin_key_files block is dropped
28
+ * (key-files pinning moved out of Magic Context).
29
+ * - classify-memories is NEW in v2 and defaults ON daily at 06:00, unless the
30
+ * whole dreamer block is disabled.
31
+ * - evaluate-smart-notes → base cron (it always ran on pending notes).
32
+ * - task_timeout_minutes → each task's timeout_minutes default; max_runtime_minutes dropped.
33
+ * - Object-shaped A+B configs carrying retired memory task keys are folded into
34
+ * verify + curate before schema parsing strips unknown keys.
35
+ */
36
+
37
+ const OLD_VERIFY_TASK = "verify";
38
+ const OLD_CURATE_TASKS = ["consolidate", "archive-stale", "improve"] as const;
39
+ const RETIRED_OBJECT_MEMORY_TASKS = ["maintain-memory", ...OLD_CURATE_TASKS] as const;
40
+ const CANONICAL = [
41
+ "map-memories",
42
+ "verify",
43
+ "verify-broad",
44
+ "curate",
45
+ "classify-memories",
46
+ "retrospective",
47
+ "maintain-docs",
48
+ "evaluate-smart-notes",
49
+ "review-user-memories",
50
+ "promote-primers",
51
+ "refresh-primers",
52
+ ] as const;
53
+ const DEFAULT_BASE_CRON = "0 2 * * *"; // matches the historical "02:00-06:00" default window start
54
+ const DEFAULT_CLASSIFY_CRON = "0 6 * * *";
55
+ const DEFAULT_RETROSPECTIVE_CRON = "0 5 * * *";
56
+ const DEFAULT_VERIFY_BROAD_CRON = "0 4 * * 0"; // weekly — replaces the old broad_interval_days cadence
57
+
58
+ /** "02:00-06:00" → "0 2 * * *". Falls back to the default base cron on any
59
+ * unparseable window (never throws — config migration is fail-open). */
60
+ function windowToCron(schedule: unknown): string {
61
+ if (typeof schedule !== "string") return DEFAULT_BASE_CRON;
62
+ const m = /^(\d{1,2}):(\d{2})\s*-/.exec(schedule.trim());
63
+ if (!m) return DEFAULT_BASE_CRON;
64
+ const hour = Number(m[1]);
65
+ const minute = Number(m[2]);
66
+ if (hour >= 24 || minute >= 60) return DEFAULT_BASE_CRON;
67
+ return `${minute} ${hour} * * *`;
68
+ }
69
+
70
+ function asObject(value: unknown): Record<string, unknown> | undefined {
71
+ return typeof value === "object" && value !== null && !Array.isArray(value)
72
+ ? (value as Record<string, unknown>)
73
+ : undefined;
74
+ }
75
+
76
+ function cronIntervalScore(schedule: string): number {
77
+ const parts = schedule.trim().split(/\s+/);
78
+ if (parts.length !== 5) return Number.POSITIVE_INFINITY;
79
+ const [minute, hour, dayOfMonth, month, dayOfWeek] = parts;
80
+ if (month !== "*") return 366 * 24 * 60;
81
+ if (dayOfMonth !== "*") return 31 * 24 * 60;
82
+ if (dayOfWeek !== "*") return 7 * 24 * 60;
83
+ const everyHour = /^\*\/(\d+)$/.exec(hour ?? "");
84
+ if (everyHour) return Math.max(1, Number(everyHour[1])) * 60;
85
+ if (hour === "*") {
86
+ const everyMinute = /^\*\/(\d+)$/.exec(minute ?? "");
87
+ return everyMinute ? Math.max(1, Number(everyMinute[1])) : 60;
88
+ }
89
+ return 24 * 60;
90
+ }
91
+
92
+ function mostFrequentSchedule(schedules: string[]): string {
93
+ const enabled = schedules.map((s) => s.trim()).filter(Boolean);
94
+ if (enabled.length === 0) return "";
95
+ return enabled.sort((a, b) => cronIntervalScore(a) - cronIntervalScore(b))[0] ?? "";
96
+ }
97
+
98
+ function withoutBroadInterval(entry: Record<string, unknown>): Record<string, unknown> {
99
+ const { broad_interval_days: _broad, ...rest } = entry;
100
+ return rest;
101
+ }
102
+
103
+ /** Surgical reconcile for an already-v2 tasks-object config (no legacy keys):
104
+ * backfill `verify-broad` (coupled to verify's enabled state) and strip the
105
+ * dead `broad_interval_days` from every task. Returns rawConfig UNCHANGED when
106
+ * nothing needs touching (so an already-reconciled config is a stable no-op /
107
+ * idempotent). */
108
+ function reconcileV2TasksObject(
109
+ rawConfig: Record<string, unknown>,
110
+ dreamer: Record<string, unknown>,
111
+ tasksObject: Record<string, unknown>,
112
+ ): Record<string, unknown> {
113
+ const hasVerifyBroad = "verify-broad" in tasksObject;
114
+ const hasBroadIntervalAnywhere = Object.values(tasksObject).some(
115
+ (v) => asObject(v) && "broad_interval_days" in (v as Record<string, unknown>),
116
+ );
117
+ // key-files was removed (feature moved to AFT); strip any stale task entry.
118
+ const hasStaleKeyFiles = "key-files" in tasksObject;
119
+ if (hasVerifyBroad && !hasBroadIntervalAnywhere && !hasStaleKeyFiles) return rawConfig;
120
+
121
+ const nextTasks: Record<string, unknown> = {};
122
+ for (const [key, value] of Object.entries(tasksObject)) {
123
+ if (key === "key-files") continue;
124
+ const obj = asObject(value);
125
+ nextTasks[key] = obj ? withoutBroadInterval(obj) : value;
126
+ }
127
+ if (!hasVerifyBroad) {
128
+ const verify = asObject(tasksObject.verify);
129
+ const verifyEnabled = typeof verify?.schedule === "string" && verify.schedule.trim() !== "";
130
+ nextTasks["verify-broad"] = {
131
+ schedule: verifyEnabled ? DEFAULT_VERIFY_BROAD_CRON : "",
132
+ };
133
+ }
134
+ return { ...rawConfig, dreamer: { ...dreamer, tasks: nextTasks } };
135
+ }
136
+
137
+ export function migrateDreamerV2(
138
+ rawConfig: Record<string, unknown>,
139
+ warnings: string[],
140
+ ): Record<string, unknown> {
141
+ const dreamer = asObject(rawConfig.dreamer);
142
+ if (!dreamer) return rawConfig;
143
+
144
+ const tasksObject = asObject(dreamer.tasks);
145
+ const hasRetiredObjectTasks = tasksObject
146
+ ? RETIRED_OBJECT_MEMORY_TASKS.some((task) => task in tasksObject)
147
+ : false;
148
+
149
+ if (tasksObject && !hasRetiredObjectTasks) {
150
+ const hasLegacyOutsideTasks =
151
+ "schedule" in dreamer ||
152
+ "user_memories" in dreamer ||
153
+ "pin_key_files" in dreamer ||
154
+ "task_timeout_minutes" in dreamer ||
155
+ "max_runtime_minutes" in dreamer;
156
+ if (!hasLegacyOutsideTasks) {
157
+ // Already a v2 tasks-object, no legacy keys → only a SURGICAL touch-up
158
+ // is needed (don't reshape an otherwise-valid config): add a
159
+ // `verify-broad` task coupled to verify's enabled state, and strip the
160
+ // dead `broad_interval_days` knob. Without this, a user who DISABLED
161
+ // verify but never wrote verify-broad gets Zod's default `0 4 * * 0`
162
+ // and unintended weekly full-pool LLM verification.
163
+ return reconcileV2TasksObject(rawConfig, dreamer, tasksObject);
164
+ }
165
+ }
166
+
167
+ // Nothing legacy to migrate (no window/array/blocks) → leave as-is; the
168
+ // schema default fills `tasks`.
169
+ const hasLegacy =
170
+ "schedule" in dreamer ||
171
+ Array.isArray(dreamer.tasks) ||
172
+ hasRetiredObjectTasks ||
173
+ "user_memories" in dreamer ||
174
+ "pin_key_files" in dreamer ||
175
+ "task_timeout_minutes" in dreamer ||
176
+ "max_runtime_minutes" in dreamer;
177
+ if (!hasLegacy) return rawConfig;
178
+
179
+ const baseCron = windowToCron(dreamer.schedule);
180
+ const timeout =
181
+ typeof dreamer.task_timeout_minutes === "number" ? dreamer.task_timeout_minutes : undefined;
182
+ const withTimeout = <T extends Record<string, unknown>>(entry: T): T =>
183
+ timeout !== undefined ? { ...entry, timeout_minutes: timeout } : entry;
184
+ const classifySchedule = dreamer.disable === true ? "" : DEFAULT_CLASSIFY_CRON;
185
+ const retrospectiveSchedule = dreamer.disable === true ? "" : DEFAULT_RETROSPECTIVE_CRON;
186
+
187
+ const tasks: Record<string, Record<string, unknown>> = {};
188
+
189
+ if (tasksObject) {
190
+ for (const [key, value] of Object.entries(tasksObject)) {
191
+ if ((RETIRED_OBJECT_MEMORY_TASKS as readonly string[]).includes(key)) continue;
192
+ if (asObject(value)) tasks[key] = { ...(value as Record<string, unknown>) };
193
+ }
194
+ const maintainMemoryEntry = asObject(tasksObject["maintain-memory"]);
195
+ if (maintainMemoryEntry) {
196
+ const schedule =
197
+ typeof maintainMemoryEntry.schedule === "string"
198
+ ? maintainMemoryEntry.schedule
199
+ : baseCron;
200
+ tasks.verify = withTimeout({
201
+ ...withoutBroadInterval(maintainMemoryEntry),
202
+ ...(tasks.verify ?? {}),
203
+ schedule: tasks.verify?.schedule ?? schedule,
204
+ });
205
+ tasks.curate = withTimeout({
206
+ ...withoutBroadInterval(maintainMemoryEntry),
207
+ ...(tasks.curate ?? {}),
208
+ schedule: tasks.curate?.schedule ?? schedule,
209
+ });
210
+ }
211
+
212
+ const oldVerifyEntry = asObject(tasksObject[OLD_VERIFY_TASK]);
213
+ if (oldVerifyEntry) {
214
+ tasks.verify = withTimeout({
215
+ ...withoutBroadInterval(oldVerifyEntry),
216
+ ...(tasks.verify ?? {}),
217
+ schedule:
218
+ tasks.verify?.schedule ??
219
+ (typeof oldVerifyEntry.schedule === "string"
220
+ ? oldVerifyEntry.schedule
221
+ : baseCron),
222
+ });
223
+ }
224
+
225
+ // The old internal broad cadence becomes its own task. If verify is
226
+ // enabled (broad used to run inside it), default verify-broad ON weekly;
227
+ // if verify is disabled, leave verify-broad disabled.
228
+ if (!tasks["verify-broad"]) {
229
+ const verifyEnabled =
230
+ typeof tasks.verify?.schedule === "string" && tasks.verify.schedule.trim() !== "";
231
+ tasks["verify-broad"] = withTimeout({
232
+ schedule: verifyEnabled ? DEFAULT_VERIFY_BROAD_CRON : "",
233
+ });
234
+ }
235
+
236
+ const oldCurateEntries = OLD_CURATE_TASKS.map((task) => asObject(tasksObject[task])).filter(
237
+ (entry): entry is Record<string, unknown> => Boolean(entry),
238
+ );
239
+ if (oldCurateEntries.length > 0) {
240
+ const oldSchedules = oldCurateEntries.map((entry) =>
241
+ typeof entry.schedule === "string" ? entry.schedule : baseCron,
242
+ );
243
+ tasks.curate = withTimeout({
244
+ ...(tasks.curate ?? {}),
245
+ schedule: mostFrequentSchedule(oldSchedules),
246
+ });
247
+ }
248
+
249
+ for (const task of CANONICAL) {
250
+ if (!tasks[task]) {
251
+ const schedule =
252
+ task === "verify" || task === "curate" || task === "verify-broad"
253
+ ? ""
254
+ : task === "classify-memories"
255
+ ? classifySchedule
256
+ : task === "retrospective"
257
+ ? retrospectiveSchedule
258
+ : task === "maintain-docs"
259
+ ? ""
260
+ : baseCron;
261
+ tasks[task] = withTimeout({ schedule });
262
+ }
263
+ }
264
+ } else {
265
+ // Agentic memory maintenance: array present → old verify enables verify,
266
+ // old consolidate/improve/archive-stale enable curate; array absent →
267
+ // historical default suite on.
268
+ const legacyArray = Array.isArray(dreamer.tasks)
269
+ ? (dreamer.tasks as unknown[]).filter((t): t is string => typeof t === "string")
270
+ : null;
271
+ const verifySelected = legacyArray ? legacyArray.includes(OLD_VERIFY_TASK) : true;
272
+ const curateSelected = legacyArray
273
+ ? legacyArray.some((task) => (OLD_CURATE_TASKS as readonly string[]).includes(task))
274
+ : true;
275
+ tasks.verify = withTimeout({
276
+ schedule: verifySelected ? baseCron : "",
277
+ });
278
+ tasks["verify-broad"] = withTimeout({
279
+ schedule: verifySelected ? DEFAULT_VERIFY_BROAD_CRON : "",
280
+ });
281
+ tasks.curate = withTimeout({
282
+ schedule: curateSelected ? baseCron : "",
283
+ });
284
+ tasks["classify-memories"] = withTimeout({
285
+ schedule: classifySchedule,
286
+ });
287
+ tasks.retrospective = withTimeout({
288
+ schedule: retrospectiveSchedule,
289
+ });
290
+ tasks["maintain-docs"] = withTimeout({
291
+ schedule: legacyArray?.includes("maintain-docs") ? baseCron : "",
292
+ });
293
+ }
294
+
295
+ // map-memories (one-time backfill) defaults on so it prepares verify; gated
296
+ // by "unmapped memories exist", so it drains then no-ops.
297
+ tasks["map-memories"] ??= withTimeout({ schedule: baseCron });
298
+
299
+ // evaluate-smart-notes always ran post-suite on pending notes.
300
+ tasks["evaluate-smart-notes"] ??= withTimeout({ schedule: baseCron });
301
+
302
+ // review-user-memories ← user_memories block (default enabled in v1).
303
+ const um = asObject(dreamer.user_memories);
304
+ const umEnabled = um ? um.enabled !== false : true;
305
+ if (um || !tasks["review-user-memories"]) {
306
+ tasks["review-user-memories"] = withTimeout({
307
+ ...(tasks["review-user-memories"] ?? {}),
308
+ schedule: umEnabled ? baseCron : "",
309
+ ...(um && typeof um.promotion_threshold === "number"
310
+ ? { promotion_threshold: um.promotion_threshold }
311
+ : {}),
312
+ });
313
+ }
314
+
315
+ // key-files was removed (the feature moved to AFT's dreamer): any legacy
316
+ // pin_key_files block is simply dropped below with the other retired keys —
317
+ // no key-files task is emitted.
318
+
319
+ // Build the new dreamer block: keep agent-config keys (model, disable, etc.),
320
+ // drop the retired scheduling keys, add the tasks record.
321
+ const {
322
+ schedule: _schedule,
323
+ tasks: _tasks,
324
+ task_timeout_minutes: _tto,
325
+ max_runtime_minutes: _max,
326
+ user_memories: _um,
327
+ pin_key_files: _pkf,
328
+ ...rest
329
+ } = dreamer;
330
+
331
+ warnings.push(
332
+ 'Migrated legacy dreamer scheduling (schedule window / tasks array / user_memories / pin_key_files) → per-task "dreamer.tasks" in-memory (run `doctor` to persist).',
333
+ );
334
+
335
+ return { ...rawConfig, dreamer: { ...rest, tasks } };
336
+ }
@@ -0,0 +1,41 @@
1
+ import { describe, expect, it } from "bun:test";
2
+
3
+ import { migrateLegacyExperimental } from "./migrate-experimental";
4
+
5
+ describe("migrateLegacyExperimental — mural", () => {
6
+ it("moves a legacy mural object and emits a deprecation warning", () => {
7
+ const warnings: string[] = [];
8
+ const migrated = migrateLegacyExperimental(
9
+ { experimental: { mural: { enabled: true, model: "provider/cues" } } },
10
+ warnings,
11
+ );
12
+
13
+ expect(migrated).toMatchObject({ mural: { enabled: true, model: "provider/cues" } });
14
+ expect((migrated.experimental as Record<string, unknown>).mural).toBeUndefined();
15
+ expect(warnings).toEqual([
16
+ 'Deprecated "experimental.mural"; use top-level "mural" instead (migrated in memory; run `doctor` to persist).',
17
+ ]);
18
+ });
19
+
20
+ it("preserves explicit top-level values when both spellings exist", () => {
21
+ const warnings: string[] = [];
22
+ const migrated = migrateLegacyExperimental(
23
+ {
24
+ mural: { enabled: false, model: "provider/new" },
25
+ experimental: { mural: { enabled: true, model: "provider/old" } },
26
+ },
27
+ warnings,
28
+ );
29
+
30
+ expect(migrated.mural).toEqual({ enabled: false, model: "provider/new" });
31
+ expect(warnings).toHaveLength(0);
32
+ });
33
+
34
+ it("coerces a legacy boolean mural into the block shape", () => {
35
+ const warnings: string[] = [];
36
+ const migrated = migrateLegacyExperimental({ experimental: { mural: true } }, warnings);
37
+
38
+ expect(migrated.mural).toEqual({ enabled: true });
39
+ expect(warnings[0]).toContain('use top-level "mural"');
40
+ });
41
+ });