@cortexkit/opencode-magic-context 0.38.1 → 0.39.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 (143) hide show
  1. package/README.md +37 -4
  2. package/dist/config/index.d.ts.map +1 -1
  3. package/dist/config/project-security.d.ts +11 -6
  4. package/dist/config/project-security.d.ts.map +1 -1
  5. package/dist/config/raw-loader.d.ts +42 -0
  6. package/dist/config/raw-loader.d.ts.map +1 -0
  7. package/dist/config/schema/magic-context.d.ts +585 -550
  8. package/dist/config/schema/magic-context.d.ts.map +1 -1
  9. package/dist/features/magic-context/dreamer/classify.d.ts +3 -2
  10. package/dist/features/magic-context/dreamer/classify.d.ts.map +1 -1
  11. package/dist/features/magic-context/dreamer/evaluate-smart-notes.d.ts +9 -2
  12. package/dist/features/magic-context/dreamer/evaluate-smart-notes.d.ts.map +1 -1
  13. package/dist/features/magic-context/dreamer/map-memories-prompt.d.ts +4 -2
  14. package/dist/features/magic-context/dreamer/map-memories-prompt.d.ts.map +1 -1
  15. package/dist/features/magic-context/dreamer/map-memories.d.ts +21 -5
  16. package/dist/features/magic-context/dreamer/map-memories.d.ts.map +1 -1
  17. package/dist/features/magic-context/dreamer/refresh-primers.d.ts +3 -2
  18. package/dist/features/magic-context/dreamer/refresh-primers.d.ts.map +1 -1
  19. package/dist/features/magic-context/dreamer/task-config.d.ts +2 -1
  20. package/dist/features/magic-context/dreamer/task-config.d.ts.map +1 -1
  21. package/dist/features/magic-context/dreamer/task-executor.d.ts +0 -1
  22. package/dist/features/magic-context/dreamer/task-executor.d.ts.map +1 -1
  23. package/dist/features/magic-context/dreamer/task-scheduler.d.ts +3 -2
  24. package/dist/features/magic-context/dreamer/task-scheduler.d.ts.map +1 -1
  25. package/dist/features/magic-context/dreamer/verify-prompt.d.ts +5 -2
  26. package/dist/features/magic-context/dreamer/verify-prompt.d.ts.map +1 -1
  27. package/dist/features/magic-context/dreamer/verify.d.ts +6 -3
  28. package/dist/features/magic-context/dreamer/verify.d.ts.map +1 -1
  29. package/dist/features/magic-context/migrations.d.ts.map +1 -1
  30. package/dist/features/magic-context/mural/compress-cues.d.ts +3 -2
  31. package/dist/features/magic-context/mural/compress-cues.d.ts.map +1 -1
  32. package/dist/features/magic-context/smart-notes/compiler.d.ts +3 -2
  33. package/dist/features/magic-context/smart-notes/compiler.d.ts.map +1 -1
  34. package/dist/features/magic-context/smart-notes/condition-compiler.d.ts +2 -0
  35. package/dist/features/magic-context/smart-notes/condition-compiler.d.ts.map +1 -1
  36. package/dist/features/magic-context/smart-notes/sandbox-runner.d.ts.map +1 -1
  37. package/dist/features/magic-context/storage-db.d.ts +1 -1
  38. package/dist/features/magic-context/storage-db.d.ts.map +1 -1
  39. package/dist/features/magic-context/storage-meta-persisted.d.ts +7 -2
  40. package/dist/features/magic-context/storage-meta-persisted.d.ts.map +1 -1
  41. package/dist/features/magic-context/storage-meta-shared.d.ts +1 -0
  42. package/dist/features/magic-context/storage-meta-shared.d.ts.map +1 -1
  43. package/dist/features/magic-context/storage-meta.d.ts +1 -1
  44. package/dist/features/magic-context/storage-meta.d.ts.map +1 -1
  45. package/dist/features/magic-context/storage-session-tables.d.ts.map +1 -1
  46. package/dist/features/magic-context/storage-tags.d.ts.map +1 -1
  47. package/dist/features/magic-context/storage.d.ts +1 -1
  48. package/dist/features/magic-context/storage.d.ts.map +1 -1
  49. package/dist/features/magic-context/tool-definition-tokens.d.ts +10 -0
  50. package/dist/features/magic-context/tool-definition-tokens.d.ts.map +1 -1
  51. package/dist/features/magic-context/transform-decision-log.d.ts +11 -0
  52. package/dist/features/magic-context/transform-decision-log.d.ts.map +1 -1
  53. package/dist/features/magic-context/user-memory/review-user-memories.d.ts +3 -2
  54. package/dist/features/magic-context/user-memory/review-user-memories.d.ts.map +1 -1
  55. package/dist/hooks/magic-context/channel2-delivery.d.ts +3 -1
  56. package/dist/hooks/magic-context/channel2-delivery.d.ts.map +1 -1
  57. package/dist/hooks/magic-context/command-handler.d.ts +4 -0
  58. package/dist/hooks/magic-context/command-handler.d.ts.map +1 -1
  59. package/dist/hooks/magic-context/compartment-runner-historian.d.ts +4 -1
  60. package/dist/hooks/magic-context/compartment-runner-historian.d.ts.map +1 -1
  61. package/dist/hooks/magic-context/compartment-runner-partial-recomp.d.ts.map +1 -1
  62. package/dist/hooks/magic-context/compartment-runner-recomp.d.ts.map +1 -1
  63. package/dist/hooks/magic-context/compartment-runner-types.d.ts +4 -1
  64. package/dist/hooks/magic-context/compartment-runner-types.d.ts.map +1 -1
  65. package/dist/hooks/magic-context/ctx-reduce-nudge.d.ts +14 -2
  66. package/dist/hooks/magic-context/ctx-reduce-nudge.d.ts.map +1 -1
  67. package/dist/hooks/magic-context/emergency-fail-closed.d.ts +7 -0
  68. package/dist/hooks/magic-context/emergency-fail-closed.d.ts.map +1 -1
  69. package/dist/hooks/magic-context/hook-handlers.d.ts.map +1 -1
  70. package/dist/hooks/magic-context/hook.d.ts.map +1 -1
  71. package/dist/hooks/magic-context/inject-compartments.d.ts +12 -1
  72. package/dist/hooks/magic-context/inject-compartments.d.ts.map +1 -1
  73. package/dist/hooks/magic-context/lkg-persist.d.ts +14 -0
  74. package/dist/hooks/magic-context/lkg-persist.d.ts.map +1 -0
  75. package/dist/hooks/magic-context/lkg-slot.d.ts +13 -0
  76. package/dist/hooks/magic-context/lkg-slot.d.ts.map +1 -1
  77. package/dist/hooks/magic-context/module-state-sync.d.ts +1 -0
  78. package/dist/hooks/magic-context/module-state-sync.d.ts.map +1 -1
  79. package/dist/hooks/magic-context/module-transport.d.ts +12 -0
  80. package/dist/hooks/magic-context/module-transport.d.ts.map +1 -1
  81. package/dist/hooks/magic-context/raw-fallback-context-limit.d.ts.map +1 -1
  82. package/dist/hooks/magic-context/recomp-orchestrator.d.ts +4 -1
  83. package/dist/hooks/magic-context/recomp-orchestrator.d.ts.map +1 -1
  84. package/dist/hooks/magic-context/rust-mode-transform.d.ts.map +1 -1
  85. package/dist/hooks/magic-context/tail-hygiene-walk.d.ts +12 -0
  86. package/dist/hooks/magic-context/tail-hygiene-walk.d.ts.map +1 -1
  87. package/dist/hooks/magic-context/transform-compartment-phase.d.ts +3 -1
  88. package/dist/hooks/magic-context/transform-compartment-phase.d.ts.map +1 -1
  89. package/dist/hooks/magic-context/transform-postprocess-phase.d.ts +4 -0
  90. package/dist/hooks/magic-context/transform-postprocess-phase.d.ts.map +1 -1
  91. package/dist/hooks/magic-context/transform.d.ts +9 -1
  92. package/dist/hooks/magic-context/transform.d.ts.map +1 -1
  93. package/dist/hooks/magic-context/wrapup-orchestrator.d.ts.map +1 -1
  94. package/dist/{index-2y0ybv5x.js → index-3dkzzh0t.js} +15 -17
  95. package/dist/index-ehpzdy0v.js +126 -0
  96. package/dist/{index-5znjkjws.js → index-k2ts0wk1.js} +486 -106
  97. package/dist/{index-22mw8km4.js → index-rw0qrkvq.js} +4 -2
  98. package/dist/index.d.ts.map +1 -1
  99. package/dist/index.js +17186 -14853
  100. package/dist/model-resolution-r9jbcz86.js +15 -0
  101. package/dist/plugin/dream-timer.d.ts +3 -0
  102. package/dist/plugin/dream-timer.d.ts.map +1 -1
  103. package/dist/plugin/hooks/create-session-hooks.d.ts.map +1 -1
  104. package/dist/plugin/messages-transform.d.ts +14 -6
  105. package/dist/plugin/messages-transform.d.ts.map +1 -1
  106. package/dist/plugin/rpc-handlers.d.ts.map +1 -1
  107. package/dist/{recomp-orchestrator-p030kxtc.js → recomp-orchestrator-2pncxee7.js} +2 -3
  108. package/dist/shared/jsonc-edit.d.ts +6 -1
  109. package/dist/shared/jsonc-edit.d.ts.map +1 -1
  110. package/dist/shared/model-resolution.d.ts +53 -0
  111. package/dist/shared/model-resolution.d.ts.map +1 -0
  112. package/dist/shared/model-suggestion-retry.d.ts +4 -1
  113. package/dist/shared/model-suggestion-retry.d.ts.map +1 -1
  114. package/dist/shared/resolve-fallbacks.d.ts +5 -1
  115. package/dist/shared/resolve-fallbacks.d.ts.map +1 -1
  116. package/dist/shared/status-detail-text.d.ts +4 -0
  117. package/dist/shared/status-detail-text.d.ts.map +1 -0
  118. package/dist/shared/subagent-runner.d.ts +2 -1
  119. package/dist/shared/subagent-runner.d.ts.map +1 -1
  120. package/dist/{storage-meta-session-2306t3j7.js → storage-meta-session-yvtbaw7c.js} +1 -1
  121. package/dist/{task-config-at3adc59.js → task-config-a1qmj9bx.js} +2 -2
  122. package/package.json +4 -2
  123. package/src/config/index.test.ts +73 -17
  124. package/src/config/index.ts +38 -23
  125. package/src/config/project-security.hostile-matrix.test.ts +471 -0
  126. package/src/config/project-security.test.ts +26 -5
  127. package/src/config/project-security.ts +141 -22
  128. package/src/config/raw-loader.test.ts +424 -0
  129. package/src/config/raw-loader.ts +490 -0
  130. package/src/config/schema/magic-context.test.ts +144 -2
  131. package/src/config/schema/magic-context.ts +253 -49
  132. package/src/shared/jsonc-edit.ts +74 -1
  133. package/src/shared/model-resolution.test.ts +170 -0
  134. package/src/shared/model-resolution.ts +227 -0
  135. package/src/shared/model-suggestion-retry.test.ts +27 -0
  136. package/src/shared/model-suggestion-retry.ts +19 -10
  137. package/src/shared/resolve-fallbacks.test.ts +13 -1
  138. package/src/shared/resolve-fallbacks.ts +19 -4
  139. package/src/shared/status-detail-text.test.ts +80 -0
  140. package/src/shared/status-detail-text.ts +56 -0
  141. package/src/shared/subagent-runner.ts +3 -1
  142. package/dist/index-td9depx5.js +0 -46
  143. 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
  }
@@ -213,17 +280,22 @@ function makeProjectThresholdWarning(field: string, reason: string): string {
213
280
  * - `transform_mode` is intentionally allowed at project tier so a repository
214
281
  * can opt its own runtime into the experimental Rust pipeline. The resolver
215
282
  * 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.
283
+ * - historian model-resolution fields (model, fallback_models, variant,
284
+ * thinking_level), including both per-harness blocks — historian model
285
+ * spend is user-level only. Qualifiers merge onto the user's historian
286
+ * model at resolve time, so a cloned repo cannot force extra thinking or
287
+ * variant cost.
288
+ * - `mural.model` at the top-level block, the legacy experimental spelling,
289
+ * and any nested `mural.model` under hidden agents — a cloned repo cannot
290
+ * choose where project memory is sent.
220
291
  * - `pi.subagent_extensions` — a cloned repo must not choose which extensions
221
292
  * the user's Pi child processes load.
222
293
  * - `prompt_surface.guidance_override_path` / `tool_descriptions` — a repository
223
294
  * may select a reviewed preset, but must not inject arbitrary guidance or tool
224
295
  * 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.
296
+ * - hidden-agent `prompt`/`permission`/`tools`/`system_prompt` at the agent
297
+ * root, each harness block, each task block, and inside model/fallback
298
+ * entry objects — a repo must not reprogram or re-permission hidden agents.
227
299
  */
228
300
  export function stripUnsafeProjectConfigFields(projectRaw: Record<string, unknown>): string[] {
229
301
  const warnings: string[] = [];
@@ -356,6 +428,49 @@ export function stripUnsafeProjectConfigFields(projectRaw: Record<string, unknow
356
428
  }
357
429
  }
358
430
 
431
+ for (const agentKey of HIDDEN_AGENT_KEYS) {
432
+ const block = projectRaw[agentKey];
433
+ if (!isPlainObject(block)) continue;
434
+ const removed: string[] = [];
435
+ stripEscalationAtExecutableSite(block, agentKey, removed);
436
+ for (const harness of HARNESS_KEYS) {
437
+ const harnessBlock = block[harness];
438
+ if (!isPlainObject(harnessBlock)) continue;
439
+ stripEscalationAtExecutableSite(harnessBlock, `${agentKey}.${harness}`, removed);
440
+ const tasks = harnessBlock.tasks;
441
+ if (isPlainObject(tasks)) {
442
+ for (const [taskName, taskBlock] of Object.entries(tasks)) {
443
+ if (isPlainObject(taskBlock)) {
444
+ stripEscalationAtExecutableSite(
445
+ taskBlock,
446
+ `${agentKey}.${harness}.tasks.${taskName}`,
447
+ removed,
448
+ );
449
+ }
450
+ }
451
+ }
452
+ }
453
+ const schedulingTasks = block.tasks;
454
+ if (isPlainObject(schedulingTasks)) {
455
+ for (const [taskName, taskBlock] of Object.entries(schedulingTasks)) {
456
+ if (isPlainObject(taskBlock)) {
457
+ stripListedFields(
458
+ taskBlock,
459
+ AGENT_ESCALATION_FIELDS,
460
+ `${agentKey}.tasks.${taskName}`,
461
+ removed,
462
+ );
463
+ }
464
+ }
465
+ }
466
+ if (removed.length > 0) {
467
+ warnings.push(
468
+ `Ignoring ${removed.join(", ")} from project config ` +
469
+ "(security: a repository cannot reprogram or re-permission hidden agents).",
470
+ );
471
+ }
472
+ }
473
+
359
474
  const historian = projectRaw.historian;
360
475
  if (isPlainObject(historian)) {
361
476
  const removed: string[] = [];
@@ -365,9 +480,19 @@ export function stripUnsafeProjectConfigFields(projectRaw: Record<string, unknow
365
480
  removed.push(field);
366
481
  }
367
482
  }
483
+ for (const harness of HARNESS_KEYS) {
484
+ const harnessBlock = historian[harness];
485
+ if (!isPlainObject(harnessBlock)) continue;
486
+ for (const field of HISTORIAN_USER_ONLY_FIELDS) {
487
+ if (field in harnessBlock) {
488
+ delete harnessBlock[field];
489
+ removed.push(`${harness}.${field}`);
490
+ }
491
+ }
492
+ }
368
493
  if (removed.length > 0) {
369
494
  warnings.push(
370
- `Ignoring historian.${removed.join("/")} from project config ` +
495
+ `Ignoring ${removed.map((path) => `historian.${path}`).join(", ")} from project config ` +
371
496
  "(security: historian model selection is user-level only; a repository cannot force extra compaction cost).",
372
497
  );
373
498
  }
@@ -394,22 +519,16 @@ export function stripUnsafeProjectConfigFields(projectRaw: Record<string, unknow
394
519
  );
395
520
  }
396
521
 
522
+ const nestedMuralRemoved: string[] = [];
397
523
  for (const agentKey of HIDDEN_AGENT_KEYS) {
398
524
  const block = projectRaw[agentKey];
399
525
  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
- }
526
+ stripNestedMuralModels(block, agentKey, nestedMuralRemoved);
527
+ }
528
+ if (nestedMuralRemoved.length > 0) {
529
+ warnings.push(
530
+ `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).`,
531
+ );
413
532
  }
414
533
 
415
534
  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
+ });