@cortexkit/opencode-magic-context 0.38.1 → 0.40.0

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 (172) hide show
  1. package/README.md +57 -4
  2. package/dist/config/index.d.ts.map +1 -1
  3. package/dist/config/profiles.d.ts +22 -0
  4. package/dist/config/profiles.d.ts.map +1 -0
  5. package/dist/config/project-security.d.ts +14 -6
  6. package/dist/config/project-security.d.ts.map +1 -1
  7. package/dist/config/raw-loader.d.ts +42 -0
  8. package/dist/config/raw-loader.d.ts.map +1 -0
  9. package/dist/config/schema/magic-context.d.ts +966 -531
  10. package/dist/config/schema/magic-context.d.ts.map +1 -1
  11. package/dist/features/magic-context/dreamer/classify.d.ts +3 -2
  12. package/dist/features/magic-context/dreamer/classify.d.ts.map +1 -1
  13. package/dist/features/magic-context/dreamer/evaluate-smart-notes.d.ts +9 -2
  14. package/dist/features/magic-context/dreamer/evaluate-smart-notes.d.ts.map +1 -1
  15. package/dist/features/magic-context/dreamer/map-memories-prompt.d.ts +4 -2
  16. package/dist/features/magic-context/dreamer/map-memories-prompt.d.ts.map +1 -1
  17. package/dist/features/magic-context/dreamer/map-memories.d.ts +21 -5
  18. package/dist/features/magic-context/dreamer/map-memories.d.ts.map +1 -1
  19. package/dist/features/magic-context/dreamer/refresh-primers.d.ts +3 -2
  20. package/dist/features/magic-context/dreamer/refresh-primers.d.ts.map +1 -1
  21. package/dist/features/magic-context/dreamer/task-config.d.ts +2 -1
  22. package/dist/features/magic-context/dreamer/task-config.d.ts.map +1 -1
  23. package/dist/features/magic-context/dreamer/task-executor.d.ts +0 -1
  24. package/dist/features/magic-context/dreamer/task-executor.d.ts.map +1 -1
  25. package/dist/features/magic-context/dreamer/task-scheduler.d.ts +3 -2
  26. package/dist/features/magic-context/dreamer/task-scheduler.d.ts.map +1 -1
  27. package/dist/features/magic-context/dreamer/verify-prompt.d.ts +5 -2
  28. package/dist/features/magic-context/dreamer/verify-prompt.d.ts.map +1 -1
  29. package/dist/features/magic-context/dreamer/verify.d.ts +6 -3
  30. package/dist/features/magic-context/dreamer/verify.d.ts.map +1 -1
  31. package/dist/features/magic-context/fail-closed-block.d.ts +11 -1
  32. package/dist/features/magic-context/fail-closed-block.d.ts.map +1 -1
  33. package/dist/features/magic-context/memory/embedding-failure.d.ts +11 -0
  34. package/dist/features/magic-context/memory/embedding-failure.d.ts.map +1 -0
  35. package/dist/features/magic-context/memory/embedding-openai.d.ts +5 -0
  36. package/dist/features/magic-context/memory/embedding-openai.d.ts.map +1 -1
  37. package/dist/features/magic-context/memory/embedding-provider.d.ts +3 -0
  38. package/dist/features/magic-context/memory/embedding-provider.d.ts.map +1 -1
  39. package/dist/features/magic-context/migrations.d.ts.map +1 -1
  40. package/dist/features/magic-context/mural/compress-cues.d.ts +3 -2
  41. package/dist/features/magic-context/mural/compress-cues.d.ts.map +1 -1
  42. package/dist/features/magic-context/project-embedding-registry.d.ts +2 -0
  43. package/dist/features/magic-context/project-embedding-registry.d.ts.map +1 -1
  44. package/dist/features/magic-context/smart-notes/compiler.d.ts +3 -2
  45. package/dist/features/magic-context/smart-notes/compiler.d.ts.map +1 -1
  46. package/dist/features/magic-context/smart-notes/condition-compiler.d.ts +2 -0
  47. package/dist/features/magic-context/smart-notes/condition-compiler.d.ts.map +1 -1
  48. package/dist/features/magic-context/smart-notes/sandbox-runner.d.ts.map +1 -1
  49. package/dist/features/magic-context/storage-db.d.ts +2 -2
  50. package/dist/features/magic-context/storage-db.d.ts.map +1 -1
  51. package/dist/features/magic-context/storage-meta-persisted.d.ts +7 -2
  52. package/dist/features/magic-context/storage-meta-persisted.d.ts.map +1 -1
  53. package/dist/features/magic-context/storage-meta-shared.d.ts +1 -0
  54. package/dist/features/magic-context/storage-meta-shared.d.ts.map +1 -1
  55. package/dist/features/magic-context/storage-meta.d.ts +1 -1
  56. package/dist/features/magic-context/storage-meta.d.ts.map +1 -1
  57. package/dist/features/magic-context/storage-session-tables.d.ts.map +1 -1
  58. package/dist/features/magic-context/storage-tags.d.ts.map +1 -1
  59. package/dist/features/magic-context/storage.d.ts +1 -1
  60. package/dist/features/magic-context/storage.d.ts.map +1 -1
  61. package/dist/features/magic-context/tool-definition-tokens.d.ts +10 -0
  62. package/dist/features/magic-context/tool-definition-tokens.d.ts.map +1 -1
  63. package/dist/features/magic-context/transform-decision-log.d.ts +11 -0
  64. package/dist/features/magic-context/transform-decision-log.d.ts.map +1 -1
  65. package/dist/features/magic-context/user-memory/review-user-memories.d.ts +3 -2
  66. package/dist/features/magic-context/user-memory/review-user-memories.d.ts.map +1 -1
  67. package/dist/hooks/magic-context/channel2-delivery.d.ts +3 -1
  68. package/dist/hooks/magic-context/channel2-delivery.d.ts.map +1 -1
  69. package/dist/hooks/magic-context/command-handler.d.ts +4 -0
  70. package/dist/hooks/magic-context/command-handler.d.ts.map +1 -1
  71. package/dist/hooks/magic-context/compartment-runner-historian.d.ts +4 -1
  72. package/dist/hooks/magic-context/compartment-runner-historian.d.ts.map +1 -1
  73. package/dist/hooks/magic-context/compartment-runner-partial-recomp.d.ts.map +1 -1
  74. package/dist/hooks/magic-context/compartment-runner-recomp.d.ts.map +1 -1
  75. package/dist/hooks/magic-context/compartment-runner-types.d.ts +4 -1
  76. package/dist/hooks/magic-context/compartment-runner-types.d.ts.map +1 -1
  77. package/dist/hooks/magic-context/ctx-reduce-nudge.d.ts +14 -2
  78. package/dist/hooks/magic-context/ctx-reduce-nudge.d.ts.map +1 -1
  79. package/dist/hooks/magic-context/emergency-fail-closed.d.ts +7 -0
  80. package/dist/hooks/magic-context/emergency-fail-closed.d.ts.map +1 -1
  81. package/dist/hooks/magic-context/format-embed-failure.d.ts +3 -0
  82. package/dist/hooks/magic-context/format-embed-failure.d.ts.map +1 -0
  83. package/dist/hooks/magic-context/hook-handlers.d.ts.map +1 -1
  84. package/dist/hooks/magic-context/hook.d.ts.map +1 -1
  85. package/dist/hooks/magic-context/inject-compartments.d.ts +13 -1
  86. package/dist/hooks/magic-context/inject-compartments.d.ts.map +1 -1
  87. package/dist/hooks/magic-context/lkg-persist.d.ts +14 -0
  88. package/dist/hooks/magic-context/lkg-persist.d.ts.map +1 -0
  89. package/dist/hooks/magic-context/lkg-slot.d.ts +13 -0
  90. package/dist/hooks/magic-context/lkg-slot.d.ts.map +1 -1
  91. package/dist/hooks/magic-context/module-state-sync.d.ts +1 -0
  92. package/dist/hooks/magic-context/module-state-sync.d.ts.map +1 -1
  93. package/dist/hooks/magic-context/module-transport.d.ts +12 -0
  94. package/dist/hooks/magic-context/module-transport.d.ts.map +1 -1
  95. package/dist/hooks/magic-context/raw-fallback-context-limit.d.ts.map +1 -1
  96. package/dist/hooks/magic-context/recomp-orchestrator.d.ts +4 -1
  97. package/dist/hooks/magic-context/recomp-orchestrator.d.ts.map +1 -1
  98. package/dist/hooks/magic-context/rust-mode-transform.d.ts.map +1 -1
  99. package/dist/hooks/magic-context/strip-content.d.ts +6 -0
  100. package/dist/hooks/magic-context/strip-content.d.ts.map +1 -1
  101. package/dist/hooks/magic-context/tail-hygiene-walk.d.ts +12 -0
  102. package/dist/hooks/magic-context/tail-hygiene-walk.d.ts.map +1 -1
  103. package/dist/hooks/magic-context/transform-compartment-phase.d.ts +3 -1
  104. package/dist/hooks/magic-context/transform-compartment-phase.d.ts.map +1 -1
  105. package/dist/hooks/magic-context/transform-postprocess-phase.d.ts +5 -0
  106. package/dist/hooks/magic-context/transform-postprocess-phase.d.ts.map +1 -1
  107. package/dist/hooks/magic-context/transform.d.ts +9 -1
  108. package/dist/hooks/magic-context/transform.d.ts.map +1 -1
  109. package/dist/hooks/magic-context/wrapup-orchestrator.d.ts.map +1 -1
  110. package/dist/{index-2y0ybv5x.js → index-3dkzzh0t.js} +15 -17
  111. package/dist/{index-5znjkjws.js → index-axpym70t.js} +879 -170
  112. package/dist/index-ehpzdy0v.js +126 -0
  113. package/dist/{index-22mw8km4.js → index-rw0qrkvq.js} +4 -2
  114. package/dist/index.d.ts.map +1 -1
  115. package/dist/index.js +18466 -16177
  116. package/dist/model-resolution-r9jbcz86.js +15 -0
  117. package/dist/plugin/dream-timer.d.ts +3 -0
  118. package/dist/plugin/dream-timer.d.ts.map +1 -1
  119. package/dist/plugin/hooks/create-session-hooks.d.ts +2 -0
  120. package/dist/plugin/hooks/create-session-hooks.d.ts.map +1 -1
  121. package/dist/plugin/messages-transform.d.ts +14 -6
  122. package/dist/plugin/messages-transform.d.ts.map +1 -1
  123. package/dist/plugin/rpc-handlers.d.ts.map +1 -1
  124. package/dist/{recomp-orchestrator-p030kxtc.js → recomp-orchestrator-dmyvqekw.js} +2 -3
  125. package/dist/shared/jsonc-edit.d.ts +6 -1
  126. package/dist/shared/jsonc-edit.d.ts.map +1 -1
  127. package/dist/shared/model-resolution.d.ts +53 -0
  128. package/dist/shared/model-resolution.d.ts.map +1 -0
  129. package/dist/shared/model-suggestion-retry.d.ts +4 -1
  130. package/dist/shared/model-suggestion-retry.d.ts.map +1 -1
  131. package/dist/shared/resolve-fallbacks.d.ts +5 -1
  132. package/dist/shared/resolve-fallbacks.d.ts.map +1 -1
  133. package/dist/shared/rpc-types.d.ts +2 -0
  134. package/dist/shared/rpc-types.d.ts.map +1 -1
  135. package/dist/shared/rpc-utils.d.ts +12 -1
  136. package/dist/shared/rpc-utils.d.ts.map +1 -1
  137. package/dist/shared/status-detail-text.d.ts +4 -0
  138. package/dist/shared/status-detail-text.d.ts.map +1 -0
  139. package/dist/shared/subagent-runner.d.ts +2 -1
  140. package/dist/shared/subagent-runner.d.ts.map +1 -1
  141. package/dist/{storage-meta-session-2306t3j7.js → storage-meta-session-yvtbaw7c.js} +1 -1
  142. package/dist/{task-config-at3adc59.js → task-config-a1qmj9bx.js} +2 -2
  143. package/dist/tui/data/context-db.d.ts.map +1 -1
  144. package/dist/tui-compiled/data/context-db.d.ts.map +1 -1
  145. package/package.json +4 -2
  146. package/src/config/index.test.ts +434 -19
  147. package/src/config/index.ts +67 -33
  148. package/src/config/profiles.ts +106 -0
  149. package/src/config/project-security.hostile-matrix.test.ts +480 -0
  150. package/src/config/project-security.test.ts +43 -5
  151. package/src/config/project-security.ts +151 -22
  152. package/src/config/raw-loader.test.ts +424 -0
  153. package/src/config/raw-loader.ts +490 -0
  154. package/src/config/schema/magic-context.test.ts +272 -2
  155. package/src/config/schema/magic-context.ts +342 -49
  156. package/src/shared/jsonc-edit.ts +74 -1
  157. package/src/shared/model-resolution.test.ts +170 -0
  158. package/src/shared/model-resolution.ts +227 -0
  159. package/src/shared/model-suggestion-retry.test.ts +27 -0
  160. package/src/shared/model-suggestion-retry.ts +19 -10
  161. package/src/shared/resolve-fallbacks.test.ts +13 -1
  162. package/src/shared/resolve-fallbacks.ts +19 -4
  163. package/src/shared/rpc-types.ts +2 -0
  164. package/src/shared/rpc-utils.test.ts +32 -0
  165. package/src/shared/rpc-utils.ts +38 -13
  166. package/src/shared/status-detail-text.test.ts +82 -0
  167. package/src/shared/status-detail-text.ts +57 -0
  168. package/src/shared/subagent-runner.ts +3 -1
  169. package/src/tui/data/context-db.ts +1 -0
  170. package/src/tui-compiled/data/context-db.ts +1 -0
  171. package/dist/index-td9depx5.js +0 -46
  172. package/dist/resolve-fallbacks-qf0ekmz9.js +0 -11
@@ -1,4 +1,7 @@
1
- import { DEFAULT_EXECUTE_THRESHOLD_PERCENTAGE } from "./schema/magic-context";
1
+ import {
2
+ DEFAULT_EXECUTE_THRESHOLD_PERCENTAGE,
3
+ PER_HARNESS_MIGRATION_INVENTORY,
4
+ } from "./schema/magic-context";
2
5
 
3
6
  /**
4
7
  * Security hardening for PROJECT-level (repo-supplied, untrusted) config.
@@ -15,7 +18,11 @@ import { DEFAULT_EXECUTE_THRESHOLD_PERCENTAGE } from "./schema/magic-context";
15
18
 
16
19
  /** Hidden agents that run with elevated/autonomous capability. */
17
20
  const HIDDEN_AGENT_KEYS = ["historian", "dreamer", "sidekick"] as const;
18
- const HISTORIAN_USER_ONLY_FIELDS = ["model", "fallback_models"] as const;
21
+ const HARNESS_KEYS = ["opencode", "pi"] as const;
22
+ /** Every historian model-resolution field, including per-harness qualifiers.
23
+ * Variant and thinking_level merge onto the user's historian model at resolve
24
+ * time, so leaving them would let a cloned repo force extra spend. */
25
+ const HISTORIAN_USER_ONLY_FIELDS = PER_HARNESS_MIGRATION_INVENTORY.historian.migrated_execution;
19
26
  const PROMPT_SURFACE_USER_ONLY_FIELDS = ["guidance_override_path", "tool_descriptions"] as const;
20
27
 
21
28
  /**
@@ -64,6 +71,66 @@ function isPlainObject(value: unknown): value is Record<string, unknown> {
64
71
  return typeof value === "object" && value !== null && !Array.isArray(value);
65
72
  }
66
73
 
74
+ function stripListedFields(
75
+ target: Record<string, unknown>,
76
+ fields: readonly string[],
77
+ path: string,
78
+ removed: string[],
79
+ ): void {
80
+ for (const field of fields) {
81
+ if (field in target) {
82
+ delete target[field];
83
+ removed.push(path.length > 0 ? `${path}.${field}` : field);
84
+ }
85
+ }
86
+ }
87
+
88
+ /** Strip prompt, permission, tools, and system_prompt from one agent, harness,
89
+ * task, or model-entry object, including each fallback_models entry. A cloned
90
+ * repo must not reprogram hidden agents through any of those nestings. */
91
+ function stripEscalationAtExecutableSite(
92
+ block: Record<string, unknown>,
93
+ path: string,
94
+ removed: string[],
95
+ ): void {
96
+ stripListedFields(block, AGENT_ESCALATION_FIELDS, path, removed);
97
+ if (isPlainObject(block.model)) {
98
+ stripListedFields(block.model, AGENT_ESCALATION_FIELDS, `${path}.model`, removed);
99
+ }
100
+ if (Array.isArray(block.fallback_models)) {
101
+ for (let index = 0; index < block.fallback_models.length; index++) {
102
+ const entry = block.fallback_models[index];
103
+ if (isPlainObject(entry)) {
104
+ stripListedFields(
105
+ entry,
106
+ AGENT_ESCALATION_FIELDS,
107
+ `${path}.fallback_models.${index}`,
108
+ removed,
109
+ );
110
+ }
111
+ }
112
+ }
113
+ }
114
+
115
+ /** Remove `mural.model` smuggled under hidden-agent trees. Top-level and
116
+ * experimental mural blocks are handled separately so their warnings stay
117
+ * specific; this walk covers harness and task nesting the schema does not
118
+ * admit but a hostile file can still write. */
119
+ function stripNestedMuralModels(
120
+ node: Record<string, unknown>,
121
+ path: string,
122
+ removed: string[],
123
+ ): void {
124
+ for (const [key, value] of Object.entries(node)) {
125
+ if (key === "mural" && isPlainObject(value) && "model" in value) {
126
+ delete value.model;
127
+ removed.push(`${path}.${key}.model`);
128
+ } else if (isPlainObject(value)) {
129
+ stripNestedMuralModels(value, `${path}.${key}`, removed);
130
+ }
131
+ }
132
+ }
133
+
67
134
  function isValidPercentageThreshold(value: unknown): value is number {
68
135
  return typeof value === "number" && Number.isFinite(value) && value >= 20 && value <= 80;
69
136
  }
@@ -189,6 +256,9 @@ function makeProjectThresholdWarning(field: string, reason: string): string {
189
256
  * over the user config. Returns warnings describing what was ignored.
190
257
  *
191
258
  * Closes:
259
+ * - `profiles` — profile definitions choose hidden-agent models and must stay
260
+ * in trusted user config; a repository may select a named profile but cannot
261
+ * supply its contents.
192
262
  * - `auto_update` — a repo must not suppress plugin self-updates (which can
193
263
  * carry security fixes).
194
264
  * - `fail_closed_blocking` — a repo must not un-block (or force-block) the
@@ -213,21 +283,33 @@ function makeProjectThresholdWarning(field: string, reason: string): string {
213
283
  * - `transform_mode` is intentionally allowed at project tier so a repository
214
284
  * can opt its own runtime into the experimental Rust pipeline. The resolver
215
285
  * requires trusted user-level `subc` configuration before Rust can activate.
216
- * - `historian.model` / `historian.fallback_models` — historian model spend is
217
- * user-level only; a cloned repo cannot force extra compaction cost.
218
- * - `mural.model` — mural cue-compressor model selection is user-level only;
219
- * a cloned repo cannot choose a model that sends project memory to a provider.
286
+ * - historian model-resolution fields (model, fallback_models, variant,
287
+ * thinking_level), including both per-harness blocks — historian model
288
+ * spend is user-level only. Qualifiers merge onto the user's historian
289
+ * model at resolve time, so a cloned repo cannot force extra thinking or
290
+ * variant cost.
291
+ * - `mural.model` at the top-level block, the legacy experimental spelling,
292
+ * and any nested `mural.model` under hidden agents — a cloned repo cannot
293
+ * choose where project memory is sent.
220
294
  * - `pi.subagent_extensions` — a cloned repo must not choose which extensions
221
295
  * the user's Pi child processes load.
222
296
  * - `prompt_surface.guidance_override_path` / `tool_descriptions` — a repository
223
297
  * may select a reviewed preset, but must not inject arbitrary guidance or tool
224
298
  * description text into the user's provider-visible prompt.
225
- * - hidden-agent `prompt`/`permission`/`tools` — a repo must not reprogram or
226
- * re-permission the historian/dreamer/sidekick.
299
+ * - hidden-agent `prompt`/`permission`/`tools`/`system_prompt` at the agent
300
+ * root, each harness block, each task block, and inside model/fallback
301
+ * entry objects — a repo must not reprogram or re-permission hidden agents.
227
302
  */
228
303
  export function stripUnsafeProjectConfigFields(projectRaw: Record<string, unknown>): string[] {
229
304
  const warnings: string[] = [];
230
305
 
306
+ if ("profiles" in projectRaw) {
307
+ delete projectRaw.profiles;
308
+ warnings.push(
309
+ "Ignoring profiles from project config (security: profile definitions are user-level only; a repository may select a named user profile with profile).",
310
+ );
311
+ }
312
+
231
313
  if ("auto_update" in projectRaw) {
232
314
  delete projectRaw.auto_update;
233
315
  warnings.push(
@@ -356,6 +438,49 @@ export function stripUnsafeProjectConfigFields(projectRaw: Record<string, unknow
356
438
  }
357
439
  }
358
440
 
441
+ for (const agentKey of HIDDEN_AGENT_KEYS) {
442
+ const block = projectRaw[agentKey];
443
+ if (!isPlainObject(block)) continue;
444
+ const removed: string[] = [];
445
+ stripEscalationAtExecutableSite(block, agentKey, removed);
446
+ for (const harness of HARNESS_KEYS) {
447
+ const harnessBlock = block[harness];
448
+ if (!isPlainObject(harnessBlock)) continue;
449
+ stripEscalationAtExecutableSite(harnessBlock, `${agentKey}.${harness}`, removed);
450
+ const tasks = harnessBlock.tasks;
451
+ if (isPlainObject(tasks)) {
452
+ for (const [taskName, taskBlock] of Object.entries(tasks)) {
453
+ if (isPlainObject(taskBlock)) {
454
+ stripEscalationAtExecutableSite(
455
+ taskBlock,
456
+ `${agentKey}.${harness}.tasks.${taskName}`,
457
+ removed,
458
+ );
459
+ }
460
+ }
461
+ }
462
+ }
463
+ const schedulingTasks = block.tasks;
464
+ if (isPlainObject(schedulingTasks)) {
465
+ for (const [taskName, taskBlock] of Object.entries(schedulingTasks)) {
466
+ if (isPlainObject(taskBlock)) {
467
+ stripListedFields(
468
+ taskBlock,
469
+ AGENT_ESCALATION_FIELDS,
470
+ `${agentKey}.tasks.${taskName}`,
471
+ removed,
472
+ );
473
+ }
474
+ }
475
+ }
476
+ if (removed.length > 0) {
477
+ warnings.push(
478
+ `Ignoring ${removed.join(", ")} from project config ` +
479
+ "(security: a repository cannot reprogram or re-permission hidden agents).",
480
+ );
481
+ }
482
+ }
483
+
359
484
  const historian = projectRaw.historian;
360
485
  if (isPlainObject(historian)) {
361
486
  const removed: string[] = [];
@@ -365,9 +490,19 @@ export function stripUnsafeProjectConfigFields(projectRaw: Record<string, unknow
365
490
  removed.push(field);
366
491
  }
367
492
  }
493
+ for (const harness of HARNESS_KEYS) {
494
+ const harnessBlock = historian[harness];
495
+ if (!isPlainObject(harnessBlock)) continue;
496
+ for (const field of HISTORIAN_USER_ONLY_FIELDS) {
497
+ if (field in harnessBlock) {
498
+ delete harnessBlock[field];
499
+ removed.push(`${harness}.${field}`);
500
+ }
501
+ }
502
+ }
368
503
  if (removed.length > 0) {
369
504
  warnings.push(
370
- `Ignoring historian.${removed.join("/")} from project config ` +
505
+ `Ignoring ${removed.map((path) => `historian.${path}`).join(", ")} from project config ` +
371
506
  "(security: historian model selection is user-level only; a repository cannot force extra compaction cost).",
372
507
  );
373
508
  }
@@ -394,22 +529,16 @@ export function stripUnsafeProjectConfigFields(projectRaw: Record<string, unknow
394
529
  );
395
530
  }
396
531
 
532
+ const nestedMuralRemoved: string[] = [];
397
533
  for (const agentKey of HIDDEN_AGENT_KEYS) {
398
534
  const block = projectRaw[agentKey];
399
535
  if (!isPlainObject(block)) continue;
400
- const removed: string[] = [];
401
- for (const field of AGENT_ESCALATION_FIELDS) {
402
- if (field in block) {
403
- delete block[field];
404
- removed.push(field);
405
- }
406
- }
407
- if (removed.length > 0) {
408
- warnings.push(
409
- `Ignoring ${agentKey}.${removed.join("/")} from project config ` +
410
- "(security: a repository cannot reprogram or re-permission hidden agents).",
411
- );
412
- }
536
+ stripNestedMuralModels(block, agentKey, nestedMuralRemoved);
537
+ }
538
+ if (nestedMuralRemoved.length > 0) {
539
+ warnings.push(
540
+ `Ignoring ${nestedMuralRemoved.join(", ")} from project config (security: the mural cue-compressor model is a user-level setting; a repository cannot choose where project memory is sent).`,
541
+ );
413
542
  }
414
543
 
415
544
  return warnings;
@@ -0,0 +1,424 @@
1
+ import { afterEach, describe, expect, it } from "bun:test";
2
+ import {
3
+ chmodSync,
4
+ mkdtempSync,
5
+ readdirSync,
6
+ readFileSync,
7
+ rmSync,
8
+ statSync,
9
+ writeFileSync,
10
+ } from "node:fs";
11
+ import { tmpdir } from "node:os";
12
+ import { join } from "node:path";
13
+
14
+ import { parseJsonc } from "../shared/jsonc-parser";
15
+ import { hasFlatKeys, loadRawConfigFile, migrateFlatDetailed } from "./raw-loader";
16
+ import { PER_HARNESS_MIGRATION_INVENTORY } from "./schema/magic-context";
17
+
18
+ const temporaryDirectories: string[] = [];
19
+
20
+ function temporaryDirectory(): string {
21
+ const directory = mkdtempSync(join(tmpdir(), "mc-per-harness-"));
22
+ temporaryDirectories.push(directory);
23
+ return directory;
24
+ }
25
+
26
+ function parse(text: string): Record<string, unknown> {
27
+ const withoutBom = text.startsWith("\uFEFF") ? text.slice(1) : text;
28
+ return parseJsonc<Record<string, unknown>>(withoutBom);
29
+ }
30
+
31
+ afterEach(() => {
32
+ for (const directory of temporaryDirectories.splice(0)) {
33
+ rmSync(directory, { recursive: true, force: true });
34
+ }
35
+ });
36
+
37
+ describe("per-harness raw config migration", () => {
38
+ it("maps flat agent and task fields without moving scheduling fields", () => {
39
+ const input = `\uFEFF{\r
40
+ // preserve this comment\r
41
+ "historian": {\r
42
+ "model": { "model": "provider/historian", "variant": "fast", "thinking_level": "high" }, // historian model note\r
43
+ "fallback_models": [{ "model": "provider/fallback", "variant": "careful", "thinking_level": "low" }],\r
44
+ "variant": "block-variant",\r
45
+ "thinking_level": "medium"\r
46
+ },\r
47
+ "dreamer": {\r
48
+ "model": "provider/dreamer",\r
49
+ "fallback_models": "provider/dreamer-fallback",\r
50
+ "tasks": {\r
51
+ "review": {\r
52
+ "schedule": "0 3 * * *",\r
53
+ "model": { "model": "provider/task", "variant": "task-variant", "thinking_level": "max" },\r
54
+ "thinking_level": "high",\r
55
+ "timeout_minutes": 45\r
56
+ },\r
57
+ "timeout-only": { "timeout_minutes": 15 }\r
58
+ }\r
59
+ },\r
60
+ "unrelated": { "keep": true }\r
61
+ }\r
62
+ `;
63
+
64
+ const migrated = migrateFlatDetailed(Buffer.from(input, "utf-8"));
65
+ const repeated = migrateFlatDetailed(Buffer.from(input, "utf-8"));
66
+ const idempotent = migrateFlatDetailed(migrated.bytes);
67
+ const output = migrated.bytes.toString("utf-8");
68
+ const config = parse(output);
69
+ const historian = config.historian as Record<string, unknown>;
70
+ const dreamer = config.dreamer as Record<string, unknown>;
71
+ const opencodeHistorian = historian.opencode as Record<string, unknown>;
72
+ const piHistorian = historian.pi as Record<string, unknown>;
73
+ const opencodeDreamer = dreamer.opencode as Record<string, unknown>;
74
+ const piDreamer = dreamer.pi as Record<string, unknown>;
75
+ const tasks = dreamer.tasks as Record<string, Record<string, unknown>>;
76
+
77
+ expect(migrated.hasFlatKeys).toBe(true);
78
+ expect(repeated.bytes).toEqual(migrated.bytes);
79
+ expect(repeated.diagnostics).toEqual(migrated.diagnostics);
80
+ expect(idempotent.bytes).toEqual(migrated.bytes);
81
+ expect(idempotent.hasFlatKeys).toBe(false);
82
+ expect(idempotent.diagnostics).toEqual([]);
83
+ expect(hasFlatKeys(migrated.bytes)).toBe(false);
84
+ expect(output.startsWith("\uFEFF")).toBe(true);
85
+ expect(output).toContain("// preserve this comment");
86
+ expect(output).toContain("// historian model note");
87
+ expect(output).toContain("\r\n");
88
+ expect(config.unrelated).toEqual({ keep: true });
89
+ expect(historian.model).toBeUndefined();
90
+ expect(opencodeHistorian).toEqual({
91
+ model: { model: "provider/historian", variant: "fast" },
92
+ fallback_models: [{ model: "provider/fallback", variant: "careful" }],
93
+ variant: "block-variant",
94
+ });
95
+ expect(piHistorian).toEqual({
96
+ model: { model: "provider/historian", thinking_level: "high" },
97
+ fallback_models: [{ model: "provider/fallback", thinking_level: "low" }],
98
+ thinking_level: "medium",
99
+ });
100
+ expect(opencodeDreamer.fallback_models).toEqual(["provider/dreamer-fallback"]);
101
+ expect(piDreamer.fallback_models).toEqual(["provider/dreamer-fallback"]);
102
+ expect(tasks.review).toEqual({ schedule: "0 3 * * *" });
103
+ expect(tasks["timeout-only"]).toEqual({});
104
+ expect((opencodeDreamer.tasks as Record<string, Record<string, unknown>>).review).toEqual({
105
+ model: { model: "provider/task", variant: "task-variant" },
106
+ timeout_minutes: 45,
107
+ });
108
+ expect((piDreamer.tasks as Record<string, Record<string, unknown>>).review).toEqual({
109
+ model: { model: "provider/task", thinking_level: "max" },
110
+ thinking_level: "high",
111
+ timeout_minutes: 45,
112
+ });
113
+ expect(
114
+ (opencodeDreamer.tasks as Record<string, Record<string, unknown>>)["timeout-only"],
115
+ ).toEqual({ timeout_minutes: 15 });
116
+ expect(
117
+ (piDreamer.tasks as Record<string, Record<string, unknown>>)["timeout-only"],
118
+ ).toEqual({ timeout_minutes: 15 });
119
+ });
120
+
121
+ it("turns a singleton fallback into one entry without changing its spelling", () => {
122
+ const migrated = migrateFlatDetailed(
123
+ Buffer.from(
124
+ JSON.stringify({
125
+ historian: { fallback_models: "Vendor/Model:Exact-Spelling" },
126
+ }),
127
+ ),
128
+ );
129
+ const historian = parse(migrated.bytes.toString("utf-8")).historian as Record<
130
+ string,
131
+ Record<string, unknown>
132
+ >;
133
+
134
+ expect(historian.opencode.fallback_models).toEqual(["Vendor/Model:Exact-Spelling"]);
135
+ expect(historian.pi.fallback_models).toEqual(["Vendor/Model:Exact-Spelling"]);
136
+ });
137
+
138
+ it("preserves every schema-enumerated retained field at its original level", () => {
139
+ const historianRetained = {
140
+ temperature: 0.1,
141
+ top_p: 0.9,
142
+ prompt: "historian prompt",
143
+ tools: { read: true },
144
+ disable: false,
145
+ description: "historian description",
146
+ mode: "subagent",
147
+ color: "blue",
148
+ maxSteps: 11,
149
+ permission: { edit: "deny" },
150
+ maxTokens: 4096,
151
+ two_pass: true,
152
+ disallowed_tools: ["bash"],
153
+ };
154
+ const dreamerRetained = {
155
+ temperature: 0.2,
156
+ top_p: 0.8,
157
+ prompt: "dreamer prompt",
158
+ tools: { write: true },
159
+ disable: false,
160
+ description: "dreamer description",
161
+ mode: "subagent",
162
+ color: "purple",
163
+ maxSteps: 12,
164
+ permission: { bash: "deny" },
165
+ maxTokens: 8192,
166
+ inject_docs: false,
167
+ };
168
+ const taskRetained = {
169
+ schedule: "0 3 * * *",
170
+ promotion_threshold: 4,
171
+ };
172
+ expect(Object.keys(historianRetained)).toEqual(
173
+ PER_HARNESS_MIGRATION_INVENTORY.historian.retained,
174
+ );
175
+ expect(Object.keys(dreamerRetained)).toEqual(
176
+ PER_HARNESS_MIGRATION_INVENTORY.dreamer.retained,
177
+ );
178
+ expect(Object.keys(taskRetained)).toEqual(PER_HARNESS_MIGRATION_INVENTORY.task.retained);
179
+
180
+ const migrated = migrateFlatDetailed(
181
+ Buffer.from(
182
+ JSON.stringify({
183
+ historian: { ...historianRetained, model: "provider/historian" },
184
+ dreamer: {
185
+ ...dreamerRetained,
186
+ model: "provider/dreamer",
187
+ tasks: {
188
+ review: {
189
+ ...taskRetained,
190
+ model: "provider/task",
191
+ timeout_minutes: 25,
192
+ },
193
+ },
194
+ },
195
+ }),
196
+ ),
197
+ );
198
+ const config = parse(migrated.bytes.toString("utf-8"));
199
+ const historian = config.historian as Record<string, unknown>;
200
+ const dreamer = config.dreamer as Record<string, unknown>;
201
+ const task = (dreamer.tasks as Record<string, Record<string, unknown>>).review;
202
+
203
+ for (const [field, value] of Object.entries(historianRetained)) {
204
+ expect(historian[field]).toEqual(value);
205
+ }
206
+ for (const [field, value] of Object.entries(dreamerRetained)) {
207
+ expect(dreamer[field]).toEqual(value);
208
+ }
209
+ expect(task).toEqual(taskRetained);
210
+ });
211
+
212
+ it("fills absent destinations and silently removes equal flat sources", () => {
213
+ const migrated = migrateFlatDetailed(
214
+ Buffer.from(`{
215
+ "historian": {
216
+ "model": "provider/same",
217
+ "opencode": { "model": "provider/same" },
218
+ "pi": {}
219
+ }
220
+ }`),
221
+ );
222
+ const historian = parse(migrated.bytes.toString("utf-8")).historian as Record<
223
+ string,
224
+ unknown
225
+ >;
226
+
227
+ expect(historian.model).toBeUndefined();
228
+ expect(historian.opencode).toEqual({ model: "provider/same" });
229
+ expect(historian.pi).toEqual({ model: "provider/same" });
230
+ expect(migrated.diagnostics).toEqual([]);
231
+ });
232
+
233
+ it("keeps new-shape destinations on conflicts and names both paths in warnings", () => {
234
+ const input = `{
235
+ "historian": {
236
+ "model": "provider/flat",
237
+ "fallback_models": "provider/fallback",
238
+ "opencode": { "model": "provider/opencode", "fallback_models": ["provider/fallback"] },
239
+ "pi": { "model": "provider/pi", "fallback_models": ["provider/fallback"] }
240
+ }
241
+ }`;
242
+
243
+ const migrated = migrateFlatDetailed(Buffer.from(input));
244
+ const config = parse(migrated.bytes.toString("utf-8"));
245
+ const historian = config.historian as Record<string, unknown>;
246
+
247
+ expect(historian.model).toBeUndefined();
248
+ expect(historian.fallback_models).toBeUndefined();
249
+ expect(historian.opencode).toEqual({
250
+ model: "provider/opencode",
251
+ fallback_models: ["provider/fallback"],
252
+ });
253
+ expect(historian.pi).toEqual({
254
+ model: "provider/pi",
255
+ fallback_models: ["provider/fallback"],
256
+ });
257
+ expect(migrated.diagnostics.map((diagnostic) => diagnostic.path)).toEqual([
258
+ "historian.model",
259
+ "historian.model",
260
+ ]);
261
+ expect(migrated.diagnostics[0]?.message).toContain("historian.opencode.model");
262
+ expect(migrated.diagnostics[1]?.message).toContain("historian.pi.model");
263
+ });
264
+
265
+ it("processes flat keys re-added after migration while existing blocks keep winning", () => {
266
+ const first = migrateFlatDetailed(
267
+ Buffer.from(JSON.stringify({ historian: { model: "provider/original" } })),
268
+ );
269
+ const reintroduced = parse(first.bytes.toString("utf-8"));
270
+ (reintroduced.historian as Record<string, unknown>).model = "provider/re-added";
271
+
272
+ const second = migrateFlatDetailed(Buffer.from(JSON.stringify(reintroduced)));
273
+ const historian = parse(second.bytes.toString("utf-8")).historian as Record<
274
+ string,
275
+ unknown
276
+ >;
277
+
278
+ expect(historian.model).toBeUndefined();
279
+ expect(historian.opencode).toEqual({ model: "provider/original" });
280
+ expect(historian.pi).toEqual({ model: "provider/original" });
281
+ expect(second.diagnostics.map((diagnostic) => diagnostic.message)).toEqual([
282
+ expect.stringContaining("historian.opencode.model"),
283
+ expect.stringContaining("historian.pi.model"),
284
+ ]);
285
+ });
286
+
287
+ it("rewrites user config atomically with one exact-byte restricted backup", () => {
288
+ const directory = temporaryDirectory();
289
+ const configPath = join(directory, "magic-context.jsonc");
290
+ const original = Buffer.from(
291
+ '{\n "dreamer": { "tasks": { "review": { "timeout_minutes": 30 } } }\n}\n',
292
+ );
293
+ writeFileSync(configPath, original);
294
+ chmodSync(configPath, 0o600);
295
+ const expected = migrateFlatDetailed(original).bytes;
296
+ let temporaryNamesDuringWrite: string[] = [];
297
+
298
+ const first = loadRawConfigFile({
299
+ configPath,
300
+ tier: "user",
301
+ afterTemporaryWrite: () => {
302
+ expect(readFileSync(configPath)).toEqual(original);
303
+ temporaryNamesDuringWrite = readdirSync(directory).filter((name) =>
304
+ name.endsWith(".tmp"),
305
+ );
306
+ expect(temporaryNamesDuringWrite).toHaveLength(1);
307
+ expect(
308
+ readFileSync(join(directory, temporaryNamesDuringWrite[0] as string)),
309
+ ).toEqual(expected);
310
+ },
311
+ });
312
+ const second = loadRawConfigFile({ configPath, tier: "user" });
313
+ const backupPath = `${configPath}.pre-per-harness.bak`;
314
+
315
+ expect(first?.migrated).toBe(true);
316
+ expect(first?.warnings).toContain(
317
+ "Migrated flat historian/dreamer model config to per-harness blocks.",
318
+ );
319
+ expect(readFileSync(backupPath)).toEqual(original);
320
+ expect(readFileSync(configPath)).toEqual(first?.bytes);
321
+ expect(temporaryNamesDuringWrite).toHaveLength(1);
322
+ expect(readdirSync(directory).filter((name) => name.endsWith(".tmp"))).toEqual([]);
323
+ expect(statSync(configPath).mode & 0o777).toBe(0o600);
324
+ expect(statSync(backupPath).mode & 0o777).toBe(0o600);
325
+ expect(second?.migrated).toBe(false);
326
+ expect(second?.warnings).toEqual([]);
327
+ });
328
+
329
+ it("repairs a truncated migration backup left by the direct-write protocol", () => {
330
+ const directory = temporaryDirectory();
331
+ const configPath = join(directory, "magic-context.jsonc");
332
+ const backupPath = `${configPath}.pre-per-harness.bak`;
333
+ const original = Buffer.from('{ "historian": { "model": "provider/model" } }\n');
334
+ writeFileSync(configPath, original);
335
+ writeFileSync(backupPath, original.subarray(0, 17));
336
+
337
+ const loaded = loadRawConfigFile({ configPath, tier: "user" });
338
+
339
+ expect(loaded?.migrated).toBe(true);
340
+ expect(readFileSync(backupPath)).toEqual(original);
341
+ });
342
+
343
+ it("leaves a completed migration backup with different bytes untouched", () => {
344
+ const directory = temporaryDirectory();
345
+ const configPath = join(directory, "magic-context.jsonc");
346
+ const backupPath = `${configPath}.pre-per-harness.bak`;
347
+ const sentinel = Buffer.from("not config: backup is opaque and permanent\n");
348
+ writeFileSync(configPath, '{ "historian": { "model": "provider/model" } }');
349
+ writeFileSync(backupPath, sentinel);
350
+
351
+ const first = loadRawConfigFile({ configPath, tier: "user" });
352
+ const second = loadRawConfigFile({ configPath, tier: "user" });
353
+
354
+ expect(first?.migrated).toBe(true);
355
+ expect(second?.migrated).toBe(false);
356
+ expect(readFileSync(backupPath)).toEqual(sentinel);
357
+ });
358
+
359
+ it("accepts a matching migration backup without rewriting it", () => {
360
+ const directory = temporaryDirectory();
361
+ const configPath = join(directory, "magic-context.jsonc");
362
+ const backupPath = `${configPath}.pre-per-harness.bak`;
363
+ const original = Buffer.from('{ "historian": { "model": "provider/model" } }\n');
364
+ writeFileSync(configPath, original);
365
+ writeFileSync(backupPath, original);
366
+ const backupInode = statSync(backupPath).ino;
367
+
368
+ const loaded = loadRawConfigFile({ configPath, tier: "user" });
369
+
370
+ expect(loaded?.migrated).toBe(true);
371
+ expect(readFileSync(backupPath)).toEqual(original);
372
+ expect(statSync(backupPath).ino).toBe(backupInode);
373
+ });
374
+
375
+ it("reloads the winning candidate when a concurrent loader replaces the target", () => {
376
+ const directory = temporaryDirectory();
377
+ const configPath = join(directory, "magic-context.jsonc");
378
+ const original = Buffer.from('{\n "historian": { "model": "provider/model" }\n}\n');
379
+ writeFileSync(configPath, original);
380
+
381
+ let competing: ReturnType<typeof loadRawConfigFile> | undefined;
382
+ const first = loadRawConfigFile({
383
+ configPath,
384
+ tier: "user",
385
+ afterTemporaryWrite: () => {
386
+ competing ??= loadRawConfigFile({ configPath, tier: "user" });
387
+ },
388
+ });
389
+
390
+ expect([first?.migrated, competing?.migrated].filter(Boolean)).toHaveLength(1);
391
+ expect(competing?.migrated).toBe(true);
392
+ expect(first?.migrated).toBe(false);
393
+ expect(first?.warnings).toEqual([]);
394
+ expect(competing?.warnings).toEqual([
395
+ "Migrated flat historian/dreamer model config to per-harness blocks.",
396
+ ]);
397
+ expect(first?.bytes).toEqual(competing?.bytes);
398
+ expect(parse(first?.text ?? "{}")).toEqual(parse(competing?.text ?? "{}"));
399
+ expect(readFileSync(configPath)).toEqual(competing?.bytes);
400
+ expect(readFileSync(`${configPath}.pre-per-harness.bak`)).toEqual(original);
401
+ });
402
+
403
+ it("adapts project config in memory without changing bytes or mtime", () => {
404
+ const directory = temporaryDirectory();
405
+ const configPath = join(directory, "magic-context.jsonc");
406
+ const original = Buffer.from(
407
+ '{\n "dreamer": { "tasks": { "review": { "timeout_minutes": 30 } } }\n}\n',
408
+ );
409
+ writeFileSync(configPath, original);
410
+ const before = statSync(configPath);
411
+
412
+ const loaded = loadRawConfigFile({ configPath, tier: "project" });
413
+ const after = statSync(configPath);
414
+ const config = parse(loaded?.text ?? "{}");
415
+
416
+ expect(loaded?.migrated).toBe(false);
417
+ expect(loaded?.warnings[0]).toContain("Adapted flat model config in memory");
418
+ expect(readFileSync(configPath)).toEqual(original);
419
+ expect(after.mtimeMs).toBe(before.mtimeMs);
420
+ expect(
421
+ ((config.dreamer as Record<string, unknown>).opencode as Record<string, unknown>).tasks,
422
+ ).toEqual({ review: { timeout_minutes: 30 } });
423
+ });
424
+ });