@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,252 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { DREAMER_CURATE_ALLOWED_TOOLS, DREAMER_DOCS_ALLOWED_TOOLS } from "./dreamer";
3
+ import {
4
+ applyDisallowedTools,
5
+ buildAllowOnlyPermission,
6
+ HISTORIAN_ALLOWED_TOOLS,
7
+ SIDEKICK_ALLOWED_TOOLS,
8
+ } from "./permissions";
9
+
10
+ describe("buildAllowOnlyPermission", () => {
11
+ it("starts with wildcard deny so nothing is allowed by default", () => {
12
+ const perm = buildAllowOnlyPermission([]);
13
+ expect(perm["*"]).toBe("deny");
14
+ });
15
+
16
+ it("layers the allow-list on top of the wildcard deny", () => {
17
+ const perm = buildAllowOnlyPermission(["read", "ctx_search"]);
18
+ expect(perm["*"]).toBe("deny");
19
+ expect(perm.read).toBe("allow");
20
+ expect(perm.ctx_search).toBe("allow");
21
+ });
22
+
23
+ it("places named allows AFTER the wildcard deny so findLast-semantics make them win", () => {
24
+ // OpenCode's Permission.evaluate uses `findLast` over the ruleset
25
+ // built from this object's insertion order. If "*" appeared after a
26
+ // named tool, the deny would clobber it — guard against accidental
27
+ // regressions in the helper's ordering.
28
+ const perm = buildAllowOnlyPermission(["read"]);
29
+ const keys = Object.keys(perm);
30
+ const wildcardIdx = keys.indexOf("*");
31
+ const readIdx = keys.indexOf("read");
32
+ expect(wildcardIdx).toBeLessThan(readIdx);
33
+ });
34
+
35
+ it("never accidentally allows `task`, `bash`, or `edit` unless explicitly listed", () => {
36
+ // The whole point of this helper is preventing historian / dreamer /
37
+ // sidekick from inheriting the primary-agent surface. Lock that in.
38
+ const perm = buildAllowOnlyPermission(["read"]);
39
+ expect(perm.task).toBeUndefined();
40
+ expect(perm.bash).toBeUndefined();
41
+ expect(perm.edit).toBeUndefined();
42
+ expect(perm.webfetch).toBeUndefined();
43
+ expect(perm.websearch).toBeUndefined();
44
+ // The wildcard deny covers them via findLast — verified above.
45
+ });
46
+
47
+ it("returns an empty allow-list as just the wildcard deny", () => {
48
+ const perm = buildAllowOnlyPermission([]);
49
+ expect(Object.keys(perm)).toEqual(["*"]);
50
+ });
51
+ });
52
+
53
+ describe("HISTORIAN_ALLOWED_TOOLS", () => {
54
+ it("includes `read` (for state-file offload)", () => {
55
+ // Historian's primary tool need is reading the offloaded
56
+ // existing-state XML the runner writes to a temp file.
57
+ expect(HISTORIAN_ALLOWED_TOOLS).toContain("read");
58
+ });
59
+
60
+ it("includes `aft_outline`, `aft_zoom`, `aft_search` for token-efficient repo navigation/search", () => {
61
+ // Read-only AFT navigation + search tools let historian/compressor
62
+ // find or verify a symbol or skim file structure when writing
63
+ // accurate compartment summaries without dragging in whole files.
64
+ expect(HISTORIAN_ALLOWED_TOOLS).toContain("aft_outline");
65
+ expect(HISTORIAN_ALLOWED_TOOLS).toContain("aft_zoom");
66
+ expect(HISTORIAN_ALLOWED_TOOLS).toContain("aft_search");
67
+ });
68
+
69
+ it("does NOT include `task` (the bug we're fixing — preventing subagent fanout)", () => {
70
+ expect(HISTORIAN_ALLOWED_TOOLS).not.toContain("task");
71
+ });
72
+
73
+ it("does NOT include any edit / bash / web tools", () => {
74
+ for (const dangerous of ["bash", "edit", "write", "webfetch", "websearch"]) {
75
+ expect(HISTORIAN_ALLOWED_TOOLS).not.toContain(dangerous);
76
+ }
77
+ });
78
+
79
+ it("does NOT include `grep` or `glob` (historian summarizes, not explores)", () => {
80
+ // Historian's job is summarizing the input it was given.
81
+ // Repo-wide exploration belongs to dreamer / primary agents.
82
+ expect(HISTORIAN_ALLOWED_TOOLS).not.toContain("grep");
83
+ expect(HISTORIAN_ALLOWED_TOOLS).not.toContain("glob");
84
+ });
85
+ });
86
+
87
+ describe("applyDisallowedTools", () => {
88
+ it("returns the defaults unchanged when disallowed is empty", () => {
89
+ expect(applyDisallowedTools(HISTORIAN_ALLOWED_TOOLS, [])).toEqual([
90
+ ...HISTORIAN_ALLOWED_TOOLS,
91
+ ]);
92
+ });
93
+
94
+ it('removes all tools when "*" is in the disallowed list', () => {
95
+ expect(applyDisallowedTools(HISTORIAN_ALLOWED_TOOLS, ["*"])).toEqual([]);
96
+ });
97
+
98
+ it('removes all tools when "*" appears alongside other entries', () => {
99
+ expect(applyDisallowedTools(HISTORIAN_ALLOWED_TOOLS, ["*", "read"])).toEqual([]);
100
+ });
101
+
102
+ it("removes a single tool by name", () => {
103
+ const result = applyDisallowedTools(HISTORIAN_ALLOWED_TOOLS, ["read"]);
104
+ expect(result).not.toContain("read");
105
+ expect(result).toContain("aft_outline");
106
+ expect(result).toContain("aft_zoom");
107
+ expect(result).toContain("aft_search");
108
+ });
109
+
110
+ it("removes multiple tools by name", () => {
111
+ const result = applyDisallowedTools(HISTORIAN_ALLOWED_TOOLS, ["read", "aft_search"]);
112
+ expect(result).toEqual(["aft_outline", "aft_zoom"]);
113
+ });
114
+
115
+ it("silently ignores unknown tool names (defense-in-depth)", () => {
116
+ expect(applyDisallowedTools(HISTORIAN_ALLOWED_TOOLS, ["nonexistent"])).toEqual([
117
+ ...HISTORIAN_ALLOWED_TOOLS,
118
+ ]);
119
+ });
120
+
121
+ it("produces empty allow-list → buildAllowOnlyPermission yields wildcard deny only", () => {
122
+ const allowed = applyDisallowedTools(HISTORIAN_ALLOWED_TOOLS, ["*"]);
123
+ const perm = buildAllowOnlyPermission(allowed);
124
+ expect(perm).toEqual({ "*": "deny" });
125
+ });
126
+ });
127
+
128
+ describe("DREAMER_CURATE_ALLOWED_TOOLS (base dreamer = curate only)", () => {
129
+ it("is ctx_memory ONLY — curate edits the memory store and reads no code", () => {
130
+ // A separate verify task owns memory-vs-code correctness; curate is
131
+ // pure pool hygiene, so it has no read/grep/bash/write/edit surface.
132
+ expect([...DREAMER_CURATE_ALLOWED_TOOLS]).toEqual(["ctx_memory"]);
133
+ });
134
+
135
+ it("does NOT include any codebase / shell / file-write tool", () => {
136
+ for (const denied of [
137
+ "read",
138
+ "grep",
139
+ "glob",
140
+ "bash",
141
+ "write",
142
+ "edit",
143
+ "aft_search",
144
+ "ctx_search",
145
+ "ctx_note",
146
+ "task",
147
+ ]) {
148
+ expect(DREAMER_CURATE_ALLOWED_TOOLS).not.toContain(denied);
149
+ }
150
+ });
151
+ });
152
+
153
+ describe("DREAMER_DOCS_ALLOWED_TOOLS (maintain-docs)", () => {
154
+ it("includes read/grep/glob/bash + write/edit + aft for doc maintenance", () => {
155
+ for (const tool of [
156
+ "read",
157
+ "grep",
158
+ "glob",
159
+ "bash",
160
+ "write",
161
+ "edit",
162
+ "aft_outline",
163
+ "aft_zoom",
164
+ "aft_search",
165
+ ]) {
166
+ expect(DREAMER_DOCS_ALLOWED_TOOLS).toContain(tool);
167
+ }
168
+ });
169
+
170
+ it("does NOT include memory tools (it edits docs, not the memory store)", () => {
171
+ for (const denied of ["ctx_memory", "ctx_search", "ctx_note", "task"]) {
172
+ expect(DREAMER_DOCS_ALLOWED_TOOLS).not.toContain(denied);
173
+ }
174
+ });
175
+ });
176
+
177
+ describe("SIDEKICK_ALLOWED_TOOLS", () => {
178
+ it("includes ctx_search but not ctx_memory for retrieval", () => {
179
+ // Sidekick is the /ctx-aug memory retriever. It searches memories without
180
+ // receiving the mutation-capable ctx_memory tool.
181
+ expect(SIDEKICK_ALLOWED_TOOLS).toContain("ctx_search");
182
+ expect(SIDEKICK_ALLOWED_TOOLS).not.toContain("ctx_memory");
183
+ });
184
+
185
+ it("includes `aft_outline` and `aft_zoom` for lightweight structural context", () => {
186
+ // Sidekick can pull file outline / symbol body when the user's
187
+ // prompt references a specific file or symbol, without dragging
188
+ // in whole files.
189
+ expect(SIDEKICK_ALLOWED_TOOLS).toContain("aft_outline");
190
+ expect(SIDEKICK_ALLOWED_TOOLS).toContain("aft_zoom");
191
+ });
192
+
193
+ it("does NOT include `read` (use aft_outline/aft_zoom for navigation instead)", () => {
194
+ // Sidekick should pull symbol-scoped views, not arbitrary file
195
+ // contents. If it needs full source it can use aft_zoom on a
196
+ // specific symbol.
197
+ expect(SIDEKICK_ALLOWED_TOOLS).not.toContain("read");
198
+ });
199
+
200
+ it("does NOT include `task` or any edit / bash / web tool", () => {
201
+ for (const denied of ["task", "bash", "edit", "write", "webfetch", "websearch"]) {
202
+ expect(SIDEKICK_ALLOWED_TOOLS).not.toContain(denied);
203
+ }
204
+ });
205
+ });
206
+
207
+ describe("integration: full hidden-agent permission shape", () => {
208
+ it("historian permission object: `*` denied + read + aft_outline + aft_zoom + aft_search allowed", () => {
209
+ const perm = buildAllowOnlyPermission(HISTORIAN_ALLOWED_TOOLS);
210
+ expect(perm).toEqual({
211
+ "*": "deny",
212
+ read: "allow",
213
+ aft_outline: "allow",
214
+ aft_zoom: "allow",
215
+ aft_search: "allow",
216
+ });
217
+ });
218
+
219
+ it("base dreamer (curate) permission object: `*` denied + ctx_memory only", () => {
220
+ const perm = buildAllowOnlyPermission(DREAMER_CURATE_ALLOWED_TOOLS);
221
+ expect(perm).toEqual({
222
+ "*": "deny",
223
+ ctx_memory: "allow",
224
+ });
225
+ });
226
+
227
+ it("dreamer-docs permission object: `*` denied + repo-exploration + write/edit + aft_* (no memory)", () => {
228
+ const perm = buildAllowOnlyPermission(DREAMER_DOCS_ALLOWED_TOOLS);
229
+ expect(perm).toEqual({
230
+ "*": "deny",
231
+ read: "allow",
232
+ grep: "allow",
233
+ glob: "allow",
234
+ bash: "allow",
235
+ write: "allow",
236
+ edit: "allow",
237
+ aft_outline: "allow",
238
+ aft_zoom: "allow",
239
+ aft_search: "allow",
240
+ });
241
+ });
242
+
243
+ it("sidekick permission object: `*` denied + read-only retrieval/navigation allowed", () => {
244
+ const perm = buildAllowOnlyPermission(SIDEKICK_ALLOWED_TOOLS);
245
+ expect(perm).toEqual({
246
+ "*": "deny",
247
+ ctx_search: "allow",
248
+ aft_outline: "allow",
249
+ aft_zoom: "allow",
250
+ });
251
+ });
252
+ });
@@ -0,0 +1,186 @@
1
+ import { log } from "../shared/logger";
2
+
3
+ /**
4
+ * Permission rulesets for Magic Context's hidden subagents.
5
+ *
6
+ * # Why this exists
7
+ *
8
+ * Hidden agents (`historian`, `historian-editor`, `dreamer`, `sidekick`) are
9
+ * registered with `mode: "subagent"` and `hidden: true`, but those flags
10
+ * only control visibility in the UI picker — they do NOT restrict which
11
+ * tools the spawned session can call. By default a registered subagent
12
+ * inherits the FULL primary-agent tool surface: `task`, `bash`, `edit`,
13
+ * `webfetch`, `websearch`, `read`, `grep`, `glob`, every MCP tool, etc.
14
+ *
15
+ * That default is wrong for our agents:
16
+ * - Historian should be a pure XML-emitting summarizer. It must not
17
+ * dispatch `task(subagent_type=explore)` to fan out, edit files,
18
+ * run bash, or fetch the web — its job is to read offloaded state
19
+ * files and emit `<compartment>` blocks.
20
+ * - The `task` permission only gets auto-denied when an agent is
21
+ * INVOKED via the parent's `task()` tool (see OpenCode's
22
+ * `deriveSubagentSessionPermission`). Our hidden agents are spawned
23
+ * directly via `client.session.prompt(...)` from the plugin
24
+ * runtime, so that auto-deny never fires — they get the same
25
+ * `task` permission as a primary `build` agent.
26
+ *
27
+ * # Design
28
+ *
29
+ * Each hidden agent's `permission` field starts with `{ "*": "deny" }`
30
+ * and adds explicit `allow` entries for ONLY the tool ids it needs.
31
+ * OpenCode's `Permission.fromConfig` converts this flat map into a
32
+ * `Rule[]` ruleset where later entries override earlier ones, so the
33
+ * named allows always win against the wildcard deny.
34
+ *
35
+ * This is the same pattern OpenCode's own `explore` subagent uses
36
+ * (see `packages/opencode/src/agent/agent.ts:179-201`).
37
+ *
38
+ * User-supplied agent overrides (`pluginConfig.historian.permission`,
39
+ * etc.) still merge on top via OpenCode's `Permission.merge`, so
40
+ * advanced users can extend the allow-list without us blocking them.
41
+ *
42
+ * # What each agent needs
43
+ *
44
+ * - **historian / historian-editor / compressor**: `read` plus the
45
+ * read-only AFT navigation/search tools `aft_outline`, `aft_zoom`,
46
+ * and `aft_search`. The runner offloads large existing-state XML to
47
+ * a temp file under `<project>/.opencode/magic-context/historian/`
48
+ * and the prompt instructs the model to read that file. AFT
49
+ * navigation is allowed so historian can find or verify a symbol or
50
+ * file structure when writing accurate compartment summaries.
51
+ *
52
+ * - **dreamer**: `read`, `grep`, `glob`, `bash`, `write`, `edit`, the
53
+ * read-only AFT navigation/search tools `aft_outline`, `aft_zoom`,
54
+ * `aft_search`, plus the Magic Context MCP tools `ctx_memory`,
55
+ * `ctx_search`, `ctx_note`.
56
+ * Dreamer task prompts in
57
+ * `features/magic-context/dreamer/task-prompts.ts` explicitly tell
58
+ * the model to grep schema files for defaults, read source to
59
+ * confirm claims, run `git log` / `gh` / `curl` for verify and
60
+ * smart-note evaluation, and use glob/find for directory
61
+ * inventory. Live DB shows >100 bash invocations across all
62
+ * dreamer task variants. `task` / `edit` / `write` / `webfetch` /
63
+ * `websearch` remain denied — dreamer must not spawn subagents
64
+ * or commit changes.
65
+ *
66
+ * - **sidekick**: `ctx_search`, plus the read-only AFT
67
+ * navigation tools `aft_outline` and `aft_zoom`. Sidekick's job
68
+ * is augmenting user prompts via memory retrieval — see
69
+ * `features/magic-context/sidekick/agent.ts`. AFT navigation lets
70
+ * it pull symbol-scoped structural context for prompts that
71
+ * reference a specific file or symbol.
72
+ */
73
+
74
+ /**
75
+ * Build a `permission` map suitable for `AgentConfig.permission`. Starts
76
+ * with a wildcard deny, then layers in the named tool allows on top.
77
+ * OpenCode's `Permission.fromConfig` preserves insertion order and its
78
+ * `evaluate` uses `findLast`, so named allows defeat the wildcard deny.
79
+ *
80
+ * Returns `Record<string, "deny" | "allow">` which the SDK's
81
+ * `AgentConfig.permission` type accepts via its `[key: string]: unknown`
82
+ * index signature. The same pattern is used by OpenCode's built-in
83
+ * `explore`/`scout`/`general` agents and by Alfonso for its static
84
+ * agent profiles.
85
+ */
86
+ export function buildAllowOnlyPermission(
87
+ allowedTools: readonly string[] | undefined,
88
+ agentLabel?: string,
89
+ ): Record<string, "deny" | "allow"> {
90
+ const permission: Record<string, "deny" | "allow"> = { "*": "deny" };
91
+ // Defensive: never throw on an undefined allow-list. A `for..of` on undefined
92
+ // crashes with "undefined is not an object (evaluating 'allowedTools')", and
93
+ // because this runs inside the plugin's `config` hook a throw there fails the
94
+ // ENTIRE plugin load — disabling the transform/compaction. Degrade to a
95
+ // deny-all agent instead of taking Magic Context down.
96
+ if (allowedTools === undefined) {
97
+ // Observed only inside OpenCode's plugin loader (never in an isolated
98
+ // import of the same dist), so capture WHICH agent + a stack to pin the
99
+ // real cause on the next natural restart. Should never fire.
100
+ log(
101
+ `[magic-context] buildAllowOnlyPermission: allow-list UNDEFINED for ${agentLabel ?? "unknown agent"} — registering deny-all (defensive)`,
102
+ { stackHead: new Error().stack?.split("\n").slice(1, 6).join("\n") },
103
+ );
104
+ }
105
+ for (const tool of allowedTools ?? []) {
106
+ permission[tool] = "allow";
107
+ }
108
+ return permission;
109
+ }
110
+
111
+ /**
112
+ * Tools the historian + historian-editor + compressor agents need.
113
+ *
114
+ * Historian runners offload large `<existing_state>` XML to disk and
115
+ * tell the model to `read` it before emitting the summary XML. The
116
+ * core need is `read`; we also allow the read-only AFT navigation
117
+ * tools `aft_outline` and `aft_zoom` so that if a historian/compressor
118
+ * ever needs to verify a symbol or skim a file's structure to write
119
+ * an accurate compartment summary, it can do so token-efficiently
120
+ * instead of pulling whole files via `read`.
121
+ *
122
+ * Still denied: bash, edit, write, task, grep/glob, webfetch/
123
+ * websearch. Historian's job is summarizing the input it was given,
124
+ * not exploring the repo.
125
+ */
126
+ export const HISTORIAN_ALLOWED_TOOLS = ["read", "aft_outline", "aft_zoom", "aft_search"] as const;
127
+
128
+ /**
129
+ * Subtract `disallowed` from the default historian allow-list. `"*"` removes
130
+ * all tools. Unknown tool names are silently ignored (the Zod enum in the
131
+ * config schema rejects them at parse time, so this is defense-in-depth).
132
+ */
133
+ export function applyDisallowedTools(
134
+ defaults: readonly string[],
135
+ disallowed: readonly string[],
136
+ ): readonly string[] {
137
+ if (disallowed.includes("*")) return [];
138
+ return defaults.filter((t) => !disallowed.includes(t));
139
+ }
140
+
141
+ // The old kitchen-sink DREAMER_ALLOWED_TOOLS is retired: each dreamer task now
142
+ // runs on its own scoped agent. Curate uses DREAMER_CURATE_ALLOWED_TOOLS
143
+ // (ctx_memory only) and maintain-docs uses DREAMER_DOCS_ALLOWED_TOOLS (file
144
+ // read/write/bash, no memory) — both in `dreamer.ts` alongside the other
145
+ // per-task allow-lists (mapper/classifier/etc).
146
+
147
+ /**
148
+ * Tools the sidekick agent needs. Sidekick is a read-only memory
149
+ * retriever for `/ctx-aug` — it queries the project's memory store
150
+ * through `ctx_search` only. Keep `ctx_memory` out of this list because
151
+ * its OpenCode tool definition is mutation-capable for primary agents.
152
+ *
153
+ * Also allow `aft_outline` and `aft_zoom` so sidekick can pull
154
+ * lightweight structural context about a file or symbol when the
155
+ * user's prompt references it directly — token-efficient navigation
156
+ * without dragging in whole files.
157
+ *
158
+ * Still denied: spawning subagents, edits, bash, web fetches.
159
+ */
160
+
161
+ export const DREAMER_RETROSPECTIVE_ALLOWED_TOOLS = ["ctx_search"] as const;
162
+
163
+ /**
164
+ * The refresh-primers code investigator: read + navigate + search the CURRENT
165
+ * source to answer a primer question. NO write/edit/bash (could corrupt user
166
+ * source) and NO ctx_memory/ctx_note (a ctx_memory mutation bumps the project
167
+ * memory epoch → busts m[0], breaking the primers cache-neutral contract).
168
+ */
169
+ export const DREAMER_PRIMER_INVESTIGATOR_ALLOWED_TOOLS = [
170
+ "read",
171
+ "grep",
172
+ "glob",
173
+ "aft_outline",
174
+ "aft_zoom",
175
+ "aft_search",
176
+ "ctx_search",
177
+ ] as const;
178
+
179
+ /**
180
+ * The smart-note compiler consumes untrusted note text and emits code that will
181
+ * later run in the QuickJS sandbox. It must not have ambient tools: all I/O is
182
+ * performed only when the compiled check runs through the host capability API.
183
+ */
184
+ export const SMART_NOTE_COMPILER_ALLOWED_TOOLS = [] as const;
185
+
186
+ export const SIDEKICK_ALLOWED_TOOLS = ["ctx_search", "aft_outline", "aft_zoom"] as const;
@@ -0,0 +1 @@
1
+ export const SIDEKICK_AGENT = "sidekick";
@@ -0,0 +1 @@
1
+ export const SMART_NOTE_COMPILER_AGENT = "smart-note-compiler";
@@ -0,0 +1,106 @@
1
+ /**
2
+ * User-facing spelling of the compaction-off config path, for log lines and
3
+ * command output. Built from fragments so the accessor-exclusivity guard
4
+ * (compaction-accessor-guard.test.ts) can keep rejecting literal
5
+ * `compaction.enabled` reads elsewhere — messages import this constant instead
6
+ * of inlining the path (the S5/S8 slices both re-derived it and tripped the
7
+ * guard; one constant ends that class).
8
+ */
9
+ export const COMPACTION_ENABLED_PATH = `compaction${"."}enabled`;
10
+
11
+ export function isDreamerRunnable(config: { dreamer?: { disable?: boolean } | null }): boolean {
12
+ return !!config.dreamer && config.dreamer.disable !== true;
13
+ }
14
+
15
+ export function isSidekickRunnable(config: { sidekick?: { disable?: boolean } | null }): boolean {
16
+ return !!config.sidekick && config.sidekick.disable !== true;
17
+ }
18
+
19
+ export function isHistorianRunnable(config: { historian?: { disable?: boolean } | null }): boolean {
20
+ return config.historian?.disable !== true;
21
+ }
22
+
23
+ /**
24
+ * The ONLY non-schema reader of the `compaction.enabled` config path.
25
+ * Resolves the compaction-off mode gate from a parsed Magic Context config.
26
+ * Lives beside the other subsystem toggles (isDreamerRunnable /
27
+ * isHistorianRunnable) and is IMPORTED — never re-derived — by every gate
28
+ * site (pi-plugin, cli, plugin boot, session hooks). Returns true (compaction
29
+ * ON / default behavior) when the block or field is absent.
30
+ */
31
+ export function isCompactionEnabled(config: {
32
+ compaction?: { enabled?: boolean } | null;
33
+ }): boolean {
34
+ return config.compaction?.enabled !== false;
35
+ }
36
+
37
+ function clonePlainObject(value: unknown): Record<string, unknown> | undefined {
38
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
39
+ return undefined;
40
+ }
41
+ return { ...(value as Record<string, unknown>) };
42
+ }
43
+
44
+ function migrateLegacyEnabledForAgent(args: {
45
+ patched: Record<string, unknown>;
46
+ agentName: "dreamer" | "sidekick" | "historian";
47
+ warnings: string[];
48
+ }): void {
49
+ const agent = clonePlainObject(args.patched[args.agentName]);
50
+ if (!agent || !("enabled" in agent)) return;
51
+
52
+ const enabled = agent.enabled;
53
+ const disable = agent.disable;
54
+ delete agent.enabled;
55
+
56
+ if (args.agentName === "historian") {
57
+ args.warnings.push(
58
+ 'Removed invalid "historian.enabled" in-memory (run doctor to persist).',
59
+ );
60
+ args.patched.historian = agent;
61
+ return;
62
+ }
63
+
64
+ if (args.agentName === "dreamer") {
65
+ if (disable !== true && enabled === false) {
66
+ agent.disable = true;
67
+ args.warnings.push(
68
+ 'Migrated "dreamer.enabled=false" → "dreamer.disable=true" in-memory (run doctor to persist). This now also disables manual /ctx-dream; for manual-only remove disable and set schedule="".',
69
+ );
70
+ }
71
+ // enabled=true is a no-op alias for the new default (disable=false); strip silently.
72
+ args.patched.dreamer = agent;
73
+ return;
74
+ }
75
+
76
+ if (disable !== true && enabled === false) {
77
+ agent.disable = true;
78
+ args.warnings.push(
79
+ 'Migrated "sidekick.enabled=false" → "sidekick.disable=true" in-memory (run doctor to persist).',
80
+ );
81
+ }
82
+ // enabled=true is a no-op alias for the new default; strip silently.
83
+ args.patched.sidekick = agent;
84
+ }
85
+
86
+ export function migrateLegacyAgentEnabledInMemory(
87
+ rawConfig: Record<string, unknown>,
88
+ warnings: string[],
89
+ ): Record<string, unknown> {
90
+ const shouldPatch = ["dreamer", "sidekick", "historian"].some((key) => {
91
+ const agent = rawConfig[key];
92
+ return (
93
+ typeof agent === "object" &&
94
+ agent !== null &&
95
+ !Array.isArray(agent) &&
96
+ "enabled" in agent
97
+ );
98
+ });
99
+ if (!shouldPatch) return rawConfig;
100
+
101
+ const patched: Record<string, unknown> = { ...rawConfig };
102
+ migrateLegacyEnabledForAgent({ patched, agentName: "dreamer", warnings });
103
+ migrateLegacyEnabledForAgent({ patched, agentName: "sidekick", warnings });
104
+ migrateLegacyEnabledForAgent({ patched, agentName: "historian", warnings });
105
+ return patched;
106
+ }
@@ -0,0 +1,82 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { readdirSync, readFileSync } from "node:fs";
3
+ import { join, relative, resolve } from "node:path";
4
+
5
+ // isCompactionEnabled (config/agent-disable.ts) is the ONLY non-schema reader
6
+ // of the `compaction.enabled` config path. Every gate site (pi-plugin, cli,
7
+ // plugin boot, session hooks) must IMPORT it and never re-derive the value.
8
+ // This guard asserts no other source file reads `compaction.enabled` or
9
+ // `compaction?.enabled` directly. Precedent: the runMigrations import guard
10
+ // (packages/cli/src/lib/migration-import-guard.test.ts).
11
+ //
12
+ // The schema file (config/schema/magic-context.ts) is the single producer of
13
+ // the path and is excluded; the accessor file (config/agent-disable.ts) is the
14
+ // single consumer and is excluded. The storage helpers read a DB column
15
+ // (compaction_mode_record), not the config path, so they are not in scope.
16
+
17
+ const REPOSITORY_ROOT = resolve(import.meta.dir, "../../../..");
18
+ const SOURCE_ROOTS = ["packages/cli/src", "packages/plugin/src", "packages/pi-plugin/src"];
19
+
20
+ const ALLOWED_READERS = new Set<string>([
21
+ // The accessor itself — the one permitted non-schema reader.
22
+ "packages/plugin/src/config/agent-disable.ts",
23
+ // The Zod schema that defines the path.
24
+ "packages/plugin/src/config/schema/magic-context.ts",
25
+ // project-security.ts references the path NAME in a warning string when it
26
+ // strips the project-tier field; it never reads the parsed config path.
27
+ // The strip operates on a raw Record<string, unknown> by key name, not on
28
+ // a parsed MagicContextConfig.
29
+ "packages/plugin/src/config/project-security.ts",
30
+ ]);
31
+
32
+ function sourceFiles(directory: string): string[] {
33
+ const result: string[] = [];
34
+ for (const entry of readdirSync(directory, { withFileTypes: true })) {
35
+ const path = join(directory, entry.name);
36
+ if (entry.isDirectory()) {
37
+ result.push(...sourceFiles(path));
38
+ } else if (/\.tsx?$/.test(entry.name) && !/\.test\.tsx?$/.test(entry.name)) {
39
+ result.push(path);
40
+ }
41
+ }
42
+ return result;
43
+ }
44
+
45
+ // Matches direct reads of the config path: `compaction.enabled` or
46
+ // `compaction?.enabled` as a property access (not a string literal). This is a
47
+ // structural grep, not a full AST parse — it is deliberately conservative and
48
+ // may surface false positives, which are then allow-listed only after review.
49
+ // It does NOT match the DB column `compaction_mode_record`, the accessor name
50
+ // `isCompactionEnabled`, or the schema's own `.object({ enabled: ... })`.
51
+ const COMPACTION_ENABLED_READ = /\bcompaction\??\s*\.\s*enabled\b(?!_)/;
52
+
53
+ describe("compaction.enabled accessor exclusivity (issue #266)", () => {
54
+ it("no non-schema source file reads compaction.enabled directly", () => {
55
+ const offenders: string[] = [];
56
+ for (const root of SOURCE_ROOTS) {
57
+ for (const path of sourceFiles(resolve(REPOSITORY_ROOT, root))) {
58
+ const relativePath = relative(REPOSITORY_ROOT, path);
59
+ if (ALLOWED_READERS.has(relativePath)) continue;
60
+ const source = readFileSync(path, "utf8");
61
+ if (COMPACTION_ENABLED_READ.test(source)) {
62
+ offenders.push(relativePath);
63
+ }
64
+ }
65
+ }
66
+ expect(offenders).toEqual([]);
67
+ });
68
+
69
+ it("isCompactionEnabled is exported from the accessor module", async () => {
70
+ const mod = await import("../config/agent-disable");
71
+ expect(typeof mod.isCompactionEnabled).toBe("function");
72
+ });
73
+
74
+ it("isCompactionEnabled resolves default-on for absent block and explicit true, off for false", async () => {
75
+ const { isCompactionEnabled } = await import("../config/agent-disable");
76
+ expect(isCompactionEnabled({})).toBe(true);
77
+ expect(isCompactionEnabled({ compaction: {} })).toBe(true);
78
+ expect(isCompactionEnabled({ compaction: { enabled: true } })).toBe(true);
79
+ expect(isCompactionEnabled({ compaction: { enabled: false } })).toBe(false);
80
+ expect(isCompactionEnabled({ compaction: null })).toBe(true);
81
+ });
82
+ });