@ran-sh/dsh-crew 1.9.0 → 1.10.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 (151) hide show
  1. package/.claude-plugin/marketplace.json +16 -16
  2. package/.claude-plugin/plugin.json +14 -14
  3. package/LICENSE +21 -21
  4. package/README.de.md +359 -359
  5. package/README.es.md +359 -359
  6. package/README.fr.md +359 -359
  7. package/README.hi.md +359 -359
  8. package/README.id.md +359 -359
  9. package/README.ja.md +359 -359
  10. package/README.ko.md +359 -359
  11. package/README.md +126 -126
  12. package/README.pt.md +359 -359
  13. package/README.ru.md +359 -359
  14. package/README.th.md +359 -359
  15. package/README.tr.md +359 -359
  16. package/README.vi.md +359 -359
  17. package/README.zh-TW.md +359 -359
  18. package/README.zh.md +116 -116
  19. package/agents/ds-flash.md +24 -24
  20. package/agents/ds-pro.md +24 -24
  21. package/agents/ds-reviewer.md +23 -23
  22. package/agents/ds-worker.md +23 -23
  23. package/bin/dsh-crew.mjs +16 -16
  24. package/codex/agents/ds-flash.toml +34 -34
  25. package/codex/agents/ds-pro.toml +34 -34
  26. package/codex/agents/ds-reviewer.toml +32 -32
  27. package/codex/agents/ds-worker.toml +32 -32
  28. package/codex/prompts/dsh-config.md +19 -19
  29. package/codex/prompts/dsh-status.md +5 -5
  30. package/commands/config.md +23 -23
  31. package/commands/off.md +5 -5
  32. package/commands/on.md +5 -5
  33. package/commands/status.md +9 -9
  34. package/cordis.patch.yml +4 -4
  35. package/docs/gpt-relay-extension.md +103 -103
  36. package/docs/installation.md +138 -138
  37. package/docs/job-contracts.md +103 -103
  38. package/docs/readiness-matrix.md +85 -85
  39. package/docs/ui-surfaces.md +107 -107
  40. package/official-web-bridge/cordis.patch.yml +4 -4
  41. package/official-web-bridge/entry.mjs +1 -1
  42. package/official-web-bridge/overlay-entry.mjs +59 -59
  43. package/official-web-bridge/package.json +25 -25
  44. package/package.json +3 -2
  45. package/scripts/build-client.mjs +49 -49
  46. package/scripts/live-crew-smoke.mjs +39 -39
  47. package/scripts/live-policy-matrix.mjs +177 -177
  48. package/scripts/policy-probe.mjs +101 -101
  49. package/scripts/remove-legacy-official-bridge.ps1 +89 -89
  50. package/scripts/setup.mjs +393 -393
  51. package/scripts/smoke-real.mjs +110 -110
  52. package/scripts/smoke.mjs +78 -78
  53. package/scripts/verify-crew-ui-polish.mjs +145 -145
  54. package/scripts/verify-history-ui.mjs +97 -97
  55. package/scripts/verify-installer-fix.mjs +26 -26
  56. package/scripts/verify-npm-install.mjs +311 -311
  57. package/scripts/verify-official-bridge-e2e.mjs +192 -192
  58. package/src/adaptive-routing.mjs +260 -260
  59. package/src/client/activation-summary.tsx +64 -64
  60. package/src/client/collapsible-sections.mjs +55 -55
  61. package/src/client/history-panel.tsx +108 -108
  62. package/src/client/host-readiness.mjs +71 -71
  63. package/src/client/index.tsx +1711 -1711
  64. package/src/client/model-callability-view.mjs +17 -17
  65. package/src/client/panel-chrome.tsx +40 -40
  66. package/src/client/quick-entry.tsx +10 -10
  67. package/src/client/quick-panel.tsx +275 -275
  68. package/src/client/readiness-envelope.mjs +34 -34
  69. package/src/client/surface-detection.mjs +43 -43
  70. package/src/config-readiness.mjs +226 -226
  71. package/src/credential-reference.mjs +38 -38
  72. package/src/delivery.mjs +205 -205
  73. package/src/dsh-cli-runtime.mjs +1021 -1021
  74. package/src/dsh-cohort.mjs +20 -20
  75. package/src/extension-contract.mjs +104 -104
  76. package/src/failure-classification.mjs +201 -201
  77. package/src/history/admission-gate.mjs +67 -67
  78. package/src/history/archive-store.mjs +272 -272
  79. package/src/history/cleanup-plan.mjs +89 -89
  80. package/src/history/http.mjs +32 -32
  81. package/src/history/operation.mjs +86 -86
  82. package/src/history/runner-detach.mjs +34 -34
  83. package/src/history/runner.mjs +52 -52
  84. package/src/history/runtime.mjs +36 -36
  85. package/src/history/service.mjs +177 -177
  86. package/src/history/state.mjs +27 -27
  87. package/src/hub/entry.mjs +104 -104
  88. package/src/hub/index.mjs +2694 -2694
  89. package/src/hub-client.mjs +154 -154
  90. package/src/hub-compatibility.mjs +42 -42
  91. package/src/i18n.mjs +19 -19
  92. package/src/information-flow.mjs +67 -67
  93. package/src/install/cli.mjs +28 -28
  94. package/src/install/install-legacy.mjs +711 -711
  95. package/src/install/install.mjs +483 -483
  96. package/src/install/npx-lifecycle.mjs +3456 -3456
  97. package/src/install/official-frontend-assets.mjs +78 -78
  98. package/src/install/official-web.mjs +95 -95
  99. package/src/install/payload-content.mjs +88 -88
  100. package/src/install/windows-startup.mjs +236 -236
  101. package/src/install/windows-supervisor-adapter.mjs +443 -443
  102. package/src/install/windows-supervisor-lifecycle.mjs +782 -782
  103. package/src/install/zcode.mjs +397 -397
  104. package/src/job-contracts.mjs +255 -255
  105. package/src/local-request-guard.mjs +60 -60
  106. package/src/mcp-runtime.mjs +340 -340
  107. package/src/model-callability-contract.mjs +79 -79
  108. package/src/model-catalog.mjs +180 -180
  109. package/src/model-routing.mjs +586 -586
  110. package/src/model-schedule.mjs +207 -207
  111. package/src/official-web-bridge.mjs +447 -447
  112. package/src/policy.mjs +235 -235
  113. package/src/provider-delete-adapters.mjs +1934 -1934
  114. package/src/provider-health.mjs +130 -130
  115. package/src/provider-inventory.mjs +182 -182
  116. package/src/provider-layer-migration-adapters.mjs +759 -759
  117. package/src/provider-layer-migration.mjs +198 -198
  118. package/src/provider-lifecycle-state.mjs +103 -103
  119. package/src/provider-lifecycle.mjs +252 -252
  120. package/src/provider-profile-store.mjs +390 -390
  121. package/src/provider-settings-store.mjs +633 -633
  122. package/src/provider-store-lock.mjs +67 -67
  123. package/src/readiness-matrix.mjs +181 -181
  124. package/src/removable-waiter.mjs +29 -29
  125. package/src/role-profiles.mjs +107 -107
  126. package/src/runtime-controls.mjs +84 -84
  127. package/src/runtime-identity-contract.mjs +34 -34
  128. package/src/runtime-identity.mjs +235 -235
  129. package/src/runtime-readiness-snapshot.mjs +285 -285
  130. package/src/server.mjs +581 -581
  131. package/src/session-origins.mjs +60 -60
  132. package/src/standalone-sdk.mjs +23 -23
  133. package/src/status-shard.mjs +63 -63
  134. package/src/structured-error-code.mjs +38 -38
  135. package/src/supervisor/restart-request.mjs +256 -256
  136. package/src/workflow-runtime.mjs +739 -739
  137. package/src/workflow.mjs +155 -155
  138. package/src/workspace-audit.mjs +231 -231
  139. package/src/workspace-context.mjs +146 -146
  140. package/src/workspace-isolation.mjs +463 -455
  141. package/src/workspace-readiness.mjs +32 -32
  142. package/statusline/statusline.sh +14 -14
  143. package/statusline/worker-segment.sh +35 -35
  144. package/windows/start-dsh-crew.cmd +57 -57
  145. package/windows/start-dsh-crew.ps1 +1302 -1302
  146. package/windows/supervisor-control.ps1 +467 -467
  147. package/worker.cordis.yml +67 -67
  148. package/zcode/agents/ds-reviewer.md +31 -31
  149. package/zcode/agents/ds-worker.md +31 -31
  150. package/zcode/commands/dsh-config.md +17 -17
  151. package/zcode/commands/dsh-status.md +5 -5
@@ -1,484 +1,484 @@
1
- // v0.3 persisted-config authority facade.
2
- //
3
- // The v0.2 installer/config implementation is frozen in install-legacy.mjs.
4
- // This module keeps all installer exports while replacing only global config
5
- // read/write semantics with schema-v3 canonical authority.
6
-
7
- export * from './install-legacy.mjs';
8
- export * from './windows-startup.mjs';
9
- export * from './zcode.mjs';
10
-
11
- import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
12
- import { basename, dirname, join } from 'node:path';
13
- import { homedir } from 'node:os';
1
+ // v0.3 persisted-config authority facade.
2
+ //
3
+ // The v0.2 installer/config implementation is frozen in install-legacy.mjs.
4
+ // This module keeps all installer exports while replacing only global config
5
+ // read/write semantics with schema-v3 canonical authority.
6
+
7
+ export * from './install-legacy.mjs';
8
+ export * from './windows-startup.mjs';
9
+ export * from './zcode.mjs';
10
+
11
+ import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
12
+ import { basename, dirname, join } from 'node:path';
13
+ import { homedir } from 'node:os';
14
14
  import * as legacy from './install-legacy.mjs';
15
- import { REMOVED_MULTIMODAL_KEYS } from './install-legacy.mjs';
16
- import { normalizeModelPriority } from '../model-routing.mjs';
17
- import { normalizeModelSchedule } from '../model-schedule.mjs';
18
- import { runtimeActivationMetadata } from '../runtime-controls.mjs';
19
- import {
20
- CONFIG_SCHEMA_VERSION,
21
- configHasCanonicalAuthority,
22
- normalizeGlobalConfig,
23
- normalizeLegacyGlobalConfig,
24
- } from '../policy.mjs';
25
-
26
- const GLOBAL_CONFIG_FILE = join(homedir(), '.config', 'dsh-crew', 'config.json');
27
- const COLLABORATION_MODES = ['flash-only', 'pro-only', 'balanced', 'review-pipeline', 'custom'];
28
- const TIER_STATES = ['disabled', 'manual', 'auto'];
29
-
30
- // ---- P0 isolation contract -------------------------------------------------
31
- // dsh-crew owns a dedicated DSH home and profile; repository installer/test
32
- // paths must never default to the user's official ~/.dsh or its ``web`` profile.
33
- // A fresh Crew Hub config points at the Crew-owned port only; the former
34
- // shared-profile default 3080 is treated as a legacy value to migrate away from.
35
- export const CREW_PROFILE_NAME = 'dsh-crew';
36
- export const CREW_HOME_REL = join('.config', 'dsh-crew', 'harness');
37
- export const CREW_DEFAULT_HUB_URL = 'http://127.0.0.1:3210';
38
- export const CREW_LEGACY_HUB_URL = 'http://127.0.0.1:3080';
39
- export function crewDshHome({ home = homedir() } = {}) {
40
- return join(home, CREW_HOME_REL);
41
- }
42
- export function crewProfileDir({ home = homedir() } = {}) {
43
- return join(crewDshHome({ home }), 'profiles', CREW_PROFILE_NAME);
44
- }
45
-
46
- // The isolated default set: identical to the legacy defaults except the Hub URL,
47
- // so fresh Crew installs never point at the official web profile.
48
- const CREW_GLOBAL_CONFIG_DEFAULTS = { ...legacy.GLOBAL_CONFIG_DEFAULTS, hub_url: CREW_DEFAULT_HUB_URL };
49
-
50
- export const GLOBAL_CONFIG_SCHEMA_VERSION = CONFIG_SCHEMA_VERSION;
51
- export const GLOBAL_CONFIG_DEFAULTS = Object.freeze({
52
- ...CREW_GLOBAL_CONFIG_DEFAULTS,
53
- config_schema_version: CONFIG_SCHEMA_VERSION,
54
- });
55
-
56
- function readJson(file, fallback) {
57
- try { return JSON.parse(readFileSync(file, 'utf8')); } catch { return fallback; }
58
- }
59
-
60
- function validObject(value) {
61
- return !!value && typeof value === 'object' && !Array.isArray(value);
62
- }
63
- function writeFileAtomic(file, content) {
64
- const dir = dirname(file);
65
- mkdirSync(dir, { recursive: true });
66
- const temp = join(dir, `.${basename(file)}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}.dsh-crew.tmp`);
67
- try {
68
- writeFileSync(temp, content);
69
- renameSync(temp, file);
70
- } catch (error) {
71
- try { rmSync(temp, { force: true }); } catch {}
72
- throw error;
73
- }
74
- }
75
-
76
- function sourceVersion(stored) {
77
- return Number.isInteger(stored?.config_schema_version) ? stored.config_schema_version : 0;
78
- }
79
-
80
- function legacySnapshotFrom(config = {}) {
81
- const mode = COLLABORATION_MODES.includes(config.collaboration_mode)
82
- ? config.collaboration_mode
83
- : (config.tier_policy === 'flash-only' ? 'flash-only'
84
- : config.tier_policy === 'pro-only' ? 'pro-only' : 'balanced');
85
- let flash = TIER_STATES.includes(config.flash_state) ? config.flash_state : 'auto';
86
- let pro = TIER_STATES.includes(config.pro_state) ? config.pro_state : 'auto';
87
- if (mode === 'flash-only') { flash = 'auto'; pro = 'disabled'; }
88
- else if (mode === 'pro-only') { flash = 'disabled'; pro = 'auto'; }
89
- else if (mode === 'balanced' || mode === 'review-pipeline') { flash = 'auto'; pro = 'auto'; }
90
- return {
91
- collaboration_mode: mode,
92
- tier_policy: mode === 'flash-only' ? 'flash-only' : mode === 'pro-only' ? 'pro-only' : 'auto',
93
- flash_state: flash,
94
- pro_state: pro,
95
- };
96
- }
97
-
98
- function attachReadMetadata(config, { version, canonical }) {
99
- return {
100
- ...config,
101
- config_schema_version: version,
102
- config_authority: canonical ? 'canonical' : 'legacy-import',
103
- config_migration_required: !canonical || version < CONFIG_SCHEMA_VERSION,
104
- config_activation: runtimeActivationMetadata(),
105
- };
106
- }
107
-
108
- function freshConfig() {
109
- const imported = normalizeLegacyGlobalConfig(CREW_GLOBAL_CONFIG_DEFAULTS);
110
- const normalized = normalizeGlobalConfig({
111
- ...imported,
112
- legacy: legacySnapshotFrom(imported),
113
- config_schema_version: CONFIG_SCHEMA_VERSION,
114
- });
115
- return attachReadMetadata(normalized, { version: CONFIG_SCHEMA_VERSION, canonical: true });
116
- }
117
-
118
- /**
119
- * Migrate the former shared-profile Crew Hub default (3080) to the Crew-owned
120
- * dedicated port on the read path so a fresh or recovered install can never
121
- * silently reconnect to the official web profile. Everything else is preserved.
122
- */
123
- function migrateHubUrl(config, { legacyUrl = CREW_LEGACY_HUB_URL, dedicatedUrl = CREW_DEFAULT_HUB_URL } = {}) {
124
- if (validObject(config) && config.hub_url === legacyUrl) {
125
- return { ...config, hub_url: dedicatedUrl, hub_url_migrated_from_legacy: true };
126
- }
127
- return config;
128
- }
129
-
130
- export function mergeStoredGlobalConfig(stored) {
131
- if (!validObject(stored)) return freshConfig();
132
-
133
- // Drop the keys that belonged to the removed vision / image-generation
134
- // bridge before either branch below, so a canonical config is cleaned as
135
- // well as a legacy one. Both branches spread the stored object, and the
136
- // merge deliberately keeps unknown keys, so without this the dead keys
137
- // would survive every read. Only these known-dead keys go; a genuinely
138
- // unknown key from a newer release is still preserved.
139
- stored = Object.fromEntries(
140
- Object.entries(stored).filter(([key]) => !REMOVED_MULTIMODAL_KEYS.includes(key)),
141
- );
142
-
143
- const version = sourceVersion(stored);
144
- if (configHasCanonicalAuthority(stored)) {
145
- const merged = { ...CREW_GLOBAL_CONFIG_DEFAULTS, ...stored };
146
- return attachReadMetadata(migrateHubUrl(normalizeGlobalConfig(merged)), { version, canonical: true });
147
- }
148
-
149
- // Legacy files keep the exact v0.2 import semantics. Reading is non-mutating:
150
- // migration is reported but the disk file is upgraded only on the next
151
- // explicit save.
152
- const legacyMerged = migrateHubUrl(legacy.mergeStoredGlobalConfig(stored));
153
- const normalized = normalizeLegacyGlobalConfig(legacyMerged);
154
- return attachReadMetadata(normalized, { version, canonical: false });
155
- }
156
-
157
- export function readGlobalConfig({ configFile = GLOBAL_CONFIG_FILE } = {}) {
158
- if (!existsSync(configFile)) return freshConfig();
159
- return mergeStoredGlobalConfig(readJson(configFile, {}));
160
- }
161
-
162
- function stripReadMetadata(config) {
163
- const next = { ...config };
164
- delete next.config_authority;
165
- delete next.config_migration_required;
166
- delete next.config_activation;
167
- return next;
168
- }
169
-
170
- function cloneCanonical(config) {
171
- return {
172
- ...config,
173
- execution: { ...config.execution },
174
- worker: {
175
- ...config.worker,
176
- model_policy: {
177
- ...config.worker?.model_policy,
178
- priority: [...(config.worker?.model_policy?.priority ?? [])],
179
- escalation_priority: [...(config.worker?.model_policy?.escalation_priority ?? [])],
180
- escalation: { ...config.worker?.model_policy?.escalation },
181
- },
182
- },
183
- review: {
184
- ...config.review,
185
- model_policy: {
186
- ...config.review?.model_policy,
187
- priority: [...(config.review?.model_policy?.priority ?? [])],
188
- },
189
- },
190
- legacy: validObject(config.legacy) ? { ...config.legacy } : legacySnapshotFrom(config),
191
- };
192
- }
193
-
194
- function mergeCanonicalPatch(current, patch) {
195
- const next = cloneCanonical(current);
196
- if (validObject(patch.execution)) {
197
- next.execution = { ...next.execution, ...patch.execution };
198
- if (patch.execution.enabled !== undefined) next.subagents_enabled = Boolean(patch.execution.enabled);
199
- }
200
- if (validObject(patch.worker)) {
201
- next.worker = {
202
- ...next.worker,
203
- ...patch.worker,
204
- model_policy: validObject(patch.worker.model_policy)
205
- ? {
206
- ...next.worker?.model_policy,
207
- ...patch.worker.model_policy,
208
- escalation: validObject(patch.worker.model_policy.escalation)
209
- ? { ...next.worker?.model_policy?.escalation, ...patch.worker.model_policy.escalation }
210
- : next.worker?.model_policy?.escalation,
211
- }
212
- : next.worker?.model_policy,
213
- };
214
- }
215
- if (validObject(patch.review)) {
216
- next.review = {
217
- ...next.review,
218
- ...patch.review,
219
- model_policy: validObject(patch.review.model_policy)
220
- ? { ...next.review?.model_policy, ...patch.review.model_policy }
221
- : next.review?.model_policy,
222
- };
223
- }
224
- if (validObject(patch.legacy)) next.legacy = { ...next.legacy, ...patch.legacy };
225
- // The peak/off-peak schedule replaces as a whole: its parts (windows, weekdays,
226
- // offset, per-model modes) only make sense together, so a partial merge could
227
- // pair one operator's windows with another's model list.
228
- if (validObject(patch.model_schedule)) next.model_schedule = normalizeModelSchedule(patch.model_schedule);
229
-
230
- // v0.3 still has one provider-mode control. A direct canonical patch is
231
- // allowed, but it cannot manufacture separate worker/reviewer authorities.
232
- const providerMode = patch.worker?.provider_mode ?? patch.review?.provider_mode;
233
- if (providerMode !== undefined) {
234
- next.worker = { ...next.worker, provider_mode: providerMode };
235
- next.review = { ...next.review, provider_mode: providerMode };
236
- next.worker_provider_mode = providerMode;
237
- }
238
- return next;
239
- }
240
-
241
- function legacyInputFrom(next, patch) {
242
- const input = { ...next };
243
- delete input.worker;
244
- delete input.review;
245
- delete input.execution;
246
- delete input.legacy;
247
- delete input.config_schema_version;
248
-
249
- // Preset commands own derived role state. Schema-v3 mirrors from the previous
250
- // preset must not masquerade as explicit overrides during translation.
251
- if (patch?.collaboration_mode !== undefined || patch?.tier_policy !== undefined) {
252
- delete input.worker_state;
253
- delete input.review_state;
254
- delete input.auto_review;
255
- delete input.pro_reviews_flash;
256
- // Non-custom presets own both tier states. Custom deliberately keeps the
257
- // previous states as its starting point.
258
- if (input.collaboration_mode !== 'custom') {
259
- delete input.flash_state;
260
- delete input.pro_state;
261
- }
262
- }
263
-
264
- // Individual tier-state commands own role eligibility only; the existing
265
- // role mirrors must not shadow them.
266
- if (patch?.flash_state !== undefined || patch?.pro_state !== undefined) {
267
- delete input.worker_state;
268
- delete input.review_state;
269
- }
270
-
271
- // auto_review is itself the new compatibility input. pro_reviews_flash is
272
- // older, so remove the schema-v3 auto_review mirror when translating it.
273
- if (patch?.auto_review !== undefined) delete input.review_state;
274
- if (patch?.pro_reviews_flash !== undefined) {
275
- delete input.review_state;
276
- delete input.auto_review;
277
- }
278
- return input;
279
- }
280
-
281
- function anyPatched(patch, keys) {
282
- return keys.some((key) => patch?.[key] !== undefined);
283
- }
284
-
285
- /**
286
- * Translate the old flat Settings/API surface into a schema-v3 canonical
287
- * update without reconstructing unrelated canonical-only fields. This is the
288
- * key single-authority boundary: a legacy UI command may change what it names,
289
- * but cannot erase e.g. escalation.max_attempts or future canonical metadata.
290
- */
291
- function mergeLegacyPatchIntoCanonical(current, compatibilityView, candidate, patch) {
292
- const next = cloneCanonical({
293
- ...compatibilityView,
294
- execution: current.execution,
295
- worker: current.worker,
296
- review: current.review,
297
- legacy: current.legacy,
298
- subagents_enabled: current.subagents_enabled,
299
- main_agent_mode: current.main_agent_mode,
300
- });
301
-
302
- if (patch?.subagents_enabled !== undefined) {
303
- next.subagents_enabled = candidate.subagents_enabled;
304
- next.execution.enabled = candidate.subagents_enabled;
305
- }
306
- if (patch?.main_agent_mode !== undefined) next.main_agent_mode = candidate.main_agent_mode;
307
-
308
- const executionMap = {
309
- default_effort: 'default_effort',
310
- default_timeout_seconds: 'default_timeout_seconds',
311
- mode: 'mode',
312
- max_parallel: 'max_parallel',
313
- isolation: 'isolation',
314
- };
315
- for (const [flat, canonical] of Object.entries(executionMap)) {
316
- if (patch?.[flat] !== undefined) next.execution[canonical] = candidate.execution[canonical];
317
- }
318
-
319
- // Presets own strategy + both role states + automatic-review
320
- // semantics and the canonical legacy compatibility snapshot.
321
- if (patch?.collaboration_mode !== undefined || patch?.tier_policy !== undefined) {
322
- next.worker.state = candidate.worker.state;
323
- next.review.state = candidate.review.state;
324
- next.worker.model_policy.strategy = candidate.worker.model_policy.strategy;
325
- next.review.auto_review = candidate.review.auto_review;
326
- next.legacy = legacySnapshotFrom(candidate);
327
- }
328
- // Tier-state controls are narrower: changing eligibility must not reset a
329
- // canonical model strategy or another future policy dimension. They still
330
- // update the nested legacy tier-state snapshot because that is their owned
331
- // compatibility behavior.
332
- if (patch?.flash_state !== undefined || patch?.pro_state !== undefined) {
333
- next.worker.state = candidate.worker.state;
334
- next.review.state = candidate.review.state;
335
- next.legacy = legacySnapshotFrom(candidate);
336
- }
337
- if (patch?.worker_state !== undefined) next.worker.state = candidate.worker.state;
338
- if (patch?.review_state !== undefined) next.review.state = candidate.review.state;
339
- if (patch?.auto_review !== undefined || patch?.pro_reviews_flash !== undefined) {
340
- next.review.auto_review = candidate.review.auto_review;
341
- next.review.state = candidate.review.state;
342
- }
343
-
344
- if (patch?.worker_provider_mode !== undefined) {
345
- next.worker.provider_mode = candidate.worker.provider_mode;
346
- next.review.provider_mode = candidate.worker.provider_mode;
347
- }
348
-
349
- if (anyPatched(patch, ['flash_model_priority', 'flash_model_priority_configured', 'flash_model_fallback'])) {
350
- next.worker.model_policy.priority = [...candidate.worker.model_policy.priority];
351
- next.worker.model_policy.priorityConfigured = candidate.worker.model_policy.priorityConfigured;
352
- next.worker.model_policy.fallback = candidate.worker.model_policy.fallback;
353
- }
354
- if (anyPatched(patch, ['pro_model_priority', 'pro_model_priority_configured', 'pro_model_fallback'])) {
355
- next.worker.model_policy.escalation_priority = [...candidate.worker.model_policy.escalation_priority];
356
- next.worker.model_policy.escalation_priority_configured = candidate.worker.model_policy.escalation_priority_configured;
357
- next.worker.model_policy.fallback = candidate.worker.model_policy.fallback;
358
- next.review.model_policy.priority = [...candidate.review.model_policy.priority];
359
- next.review.model_policy.priorityConfigured = candidate.review.model_policy.priorityConfigured;
360
- next.review.model_policy.fallback = candidate.review.model_policy.fallback;
361
- }
362
- if (patch?.escalate_on_failure !== undefined) {
363
- next.worker.model_policy.escalation.enabled = candidate.worker.model_policy.escalation.enabled;
364
- }
365
-
366
- return next;
367
- }
368
-
369
- /**
370
- * Persist one schema-v3 snapshot.
371
- *
372
- * Flat fields remain in the JSON as compatibility mirrors for older UI/MCP
373
- * builds, but schema-v3 readers ignore conflicting mirrors and trust the nested
374
- * canonical snapshot. Legacy flat patches are accepted as input commands and
375
- * translated only into the canonical fields they own.
376
- */
377
- export function writeGlobalConfig(patch, { configFile = GLOBAL_CONFIG_FILE } = {}) {
378
- mkdirSync(dirname(configFile), { recursive: true });
379
- const current = readGlobalConfig({ configFile });
380
- const wasCanonical = current.config_authority === 'canonical';
381
- let compatibilityView = stripReadMetadata(current);
382
-
383
- // Existing public settings surface: accept only known flat keys. Schema and
384
- // read-only authority metadata are never caller-controlled.
385
- for (const [key, value] of Object.entries(patch ?? {})) {
386
- if (value !== undefined && key in legacy.GLOBAL_CONFIG_DEFAULTS) compatibilityView[key] = value;
387
- }
388
-
389
- for (const tier of ['flash', 'pro']) {
390
- const key = `${tier}_model_priority`;
391
- if (patch?.[key] !== undefined) {
392
- compatibilityView[key] = normalizeModelPriority(patch[key]);
393
- compatibilityView[`${tier}_model_priority_configured`] = true;
394
- }
395
- }
396
-
397
- // Deprecated routing fields are input commands only. Keep their input view
398
- // coherent before the frozen migration translates them.
399
- if (patch?.tier_policy !== undefined) {
400
- compatibilityView.tier_policy = patch.tier_policy;
401
- if (patch.tier_policy === 'flash-only') compatibilityView.collaboration_mode = 'flash-only';
402
- else if (patch.tier_policy === 'pro-only') compatibilityView.collaboration_mode = 'pro-only';
403
- else if (compatibilityView.collaboration_mode === 'flash-only' || compatibilityView.collaboration_mode === 'pro-only') compatibilityView.collaboration_mode = 'balanced';
404
- }
405
- if (patch?.collaboration_mode !== undefined) {
406
- if (patch.collaboration_mode === 'flash-only') compatibilityView.tier_policy = 'flash-only';
407
- else if (patch.collaboration_mode === 'pro-only') compatibilityView.tier_policy = 'pro-only';
408
- else compatibilityView.tier_policy = 'auto';
409
- }
410
-
411
- const candidate = normalizeLegacyGlobalConfig(legacyInputFrom(compatibilityView, patch));
412
- let normalized = wasCanonical
413
- ? mergeLegacyPatchIntoCanonical(current, compatibilityView, candidate, patch)
414
- : { ...candidate, legacy: legacySnapshotFrom(candidate) };
415
-
416
- // `model_schedule` is canonical-only (it has no flat mirror), so it must open
417
- // the canonical merge path on its own rather than ride along with worker/review.
418
- const hasCanonicalPatch = validObject(patch?.worker)
419
- || validObject(patch?.review)
420
- || validObject(patch?.execution)
421
- || validObject(patch?.legacy)
422
- || validObject(patch?.model_schedule);
423
- if (hasCanonicalPatch) normalized = mergeCanonicalPatch(normalized, patch);
424
-
425
- if (!validObject(normalized.legacy)) normalized.legacy = legacySnapshotFrom(candidate);
426
- normalized = normalizeGlobalConfig({
427
- ...normalized,
428
- config_schema_version: CONFIG_SCHEMA_VERSION,
429
- });
430
-
431
- const persisted = stripReadMetadata({
432
- ...normalized,
433
- config_schema_version: CONFIG_SCHEMA_VERSION,
434
- });
435
- writeFileAtomic(configFile, JSON.stringify(persisted, null, 2) + '\n');
436
- return attachReadMetadata(normalizeGlobalConfig(persisted), {
437
- version: CONFIG_SCHEMA_VERSION,
438
- canonical: true,
439
- });
440
- }
441
-
442
- export function getGlobalConfigDiagnostics({ configFile = GLOBAL_CONFIG_FILE } = {}) {
443
- if (!existsSync(configFile)) {
444
- return {
445
- current_schema_version: CONFIG_SCHEMA_VERSION,
446
- stored_schema_version: null,
447
- authority: 'canonical',
448
- migration_required: false,
449
- canonical_present: true,
450
- legacy_mirror_conflicts: [],
451
- };
452
- }
453
-
454
- const stored = readJson(configFile, {});
455
- const version = sourceVersion(stored);
456
- const canonical = configHasCanonicalAuthority(stored);
457
- const conflicts = [];
458
- if (canonical) {
459
- const effective = normalizeGlobalConfig(stored);
460
- const mirrorKeys = [
461
- 'subagents_enabled', 'main_agent_mode', 'default_effort',
462
- 'default_timeout_seconds', 'mode', 'max_parallel', 'isolation',
463
- 'collaboration_mode', 'tier_policy', 'flash_state', 'pro_state',
464
- 'worker_state', 'review_state', 'auto_review', 'worker_provider_mode',
465
- 'flash_model_priority', 'flash_model_priority_configured',
466
- 'flash_model_fallback', 'pro_model_priority',
467
- 'pro_model_priority_configured', 'pro_model_fallback',
468
- 'escalate_on_failure', 'pro_reviews_flash',
469
- ];
470
- for (const key of mirrorKeys) {
471
- if (!Object.prototype.hasOwnProperty.call(stored, key)) continue;
472
- if (JSON.stringify(stored[key]) !== JSON.stringify(effective[key])) conflicts.push(key);
473
- }
474
- }
475
-
476
- return {
477
- current_schema_version: CONFIG_SCHEMA_VERSION,
478
- stored_schema_version: version,
479
- authority: canonical ? 'canonical' : 'legacy-import',
480
- migration_required: !canonical,
481
- canonical_present: canonical,
482
- legacy_mirror_conflicts: conflicts,
483
- };
484
- }
15
+ import { REMOVED_MULTIMODAL_KEYS } from './install-legacy.mjs';
16
+ import { normalizeModelPriority } from '../model-routing.mjs';
17
+ import { normalizeModelSchedule } from '../model-schedule.mjs';
18
+ import { runtimeActivationMetadata } from '../runtime-controls.mjs';
19
+ import {
20
+ CONFIG_SCHEMA_VERSION,
21
+ configHasCanonicalAuthority,
22
+ normalizeGlobalConfig,
23
+ normalizeLegacyGlobalConfig,
24
+ } from '../policy.mjs';
25
+
26
+ const GLOBAL_CONFIG_FILE = join(homedir(), '.config', 'dsh-crew', 'config.json');
27
+ const COLLABORATION_MODES = ['flash-only', 'pro-only', 'balanced', 'review-pipeline', 'custom'];
28
+ const TIER_STATES = ['disabled', 'manual', 'auto'];
29
+
30
+ // ---- P0 isolation contract -------------------------------------------------
31
+ // dsh-crew owns a dedicated DSH home and profile; repository installer/test
32
+ // paths must never default to the user's official ~/.dsh or its ``web`` profile.
33
+ // A fresh Crew Hub config points at the Crew-owned port only; the former
34
+ // shared-profile default 3080 is treated as a legacy value to migrate away from.
35
+ export const CREW_PROFILE_NAME = 'dsh-crew';
36
+ export const CREW_HOME_REL = join('.config', 'dsh-crew', 'harness');
37
+ export const CREW_DEFAULT_HUB_URL = 'http://127.0.0.1:3210';
38
+ export const CREW_LEGACY_HUB_URL = 'http://127.0.0.1:3080';
39
+ export function crewDshHome({ home = homedir() } = {}) {
40
+ return join(home, CREW_HOME_REL);
41
+ }
42
+ export function crewProfileDir({ home = homedir() } = {}) {
43
+ return join(crewDshHome({ home }), 'profiles', CREW_PROFILE_NAME);
44
+ }
45
+
46
+ // The isolated default set: identical to the legacy defaults except the Hub URL,
47
+ // so fresh Crew installs never point at the official web profile.
48
+ const CREW_GLOBAL_CONFIG_DEFAULTS = { ...legacy.GLOBAL_CONFIG_DEFAULTS, hub_url: CREW_DEFAULT_HUB_URL };
49
+
50
+ export const GLOBAL_CONFIG_SCHEMA_VERSION = CONFIG_SCHEMA_VERSION;
51
+ export const GLOBAL_CONFIG_DEFAULTS = Object.freeze({
52
+ ...CREW_GLOBAL_CONFIG_DEFAULTS,
53
+ config_schema_version: CONFIG_SCHEMA_VERSION,
54
+ });
55
+
56
+ function readJson(file, fallback) {
57
+ try { return JSON.parse(readFileSync(file, 'utf8')); } catch { return fallback; }
58
+ }
59
+
60
+ function validObject(value) {
61
+ return !!value && typeof value === 'object' && !Array.isArray(value);
62
+ }
63
+ function writeFileAtomic(file, content) {
64
+ const dir = dirname(file);
65
+ mkdirSync(dir, { recursive: true });
66
+ const temp = join(dir, `.${basename(file)}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}.dsh-crew.tmp`);
67
+ try {
68
+ writeFileSync(temp, content);
69
+ renameSync(temp, file);
70
+ } catch (error) {
71
+ try { rmSync(temp, { force: true }); } catch {}
72
+ throw error;
73
+ }
74
+ }
75
+
76
+ function sourceVersion(stored) {
77
+ return Number.isInteger(stored?.config_schema_version) ? stored.config_schema_version : 0;
78
+ }
79
+
80
+ function legacySnapshotFrom(config = {}) {
81
+ const mode = COLLABORATION_MODES.includes(config.collaboration_mode)
82
+ ? config.collaboration_mode
83
+ : (config.tier_policy === 'flash-only' ? 'flash-only'
84
+ : config.tier_policy === 'pro-only' ? 'pro-only' : 'balanced');
85
+ let flash = TIER_STATES.includes(config.flash_state) ? config.flash_state : 'auto';
86
+ let pro = TIER_STATES.includes(config.pro_state) ? config.pro_state : 'auto';
87
+ if (mode === 'flash-only') { flash = 'auto'; pro = 'disabled'; }
88
+ else if (mode === 'pro-only') { flash = 'disabled'; pro = 'auto'; }
89
+ else if (mode === 'balanced' || mode === 'review-pipeline') { flash = 'auto'; pro = 'auto'; }
90
+ return {
91
+ collaboration_mode: mode,
92
+ tier_policy: mode === 'flash-only' ? 'flash-only' : mode === 'pro-only' ? 'pro-only' : 'auto',
93
+ flash_state: flash,
94
+ pro_state: pro,
95
+ };
96
+ }
97
+
98
+ function attachReadMetadata(config, { version, canonical }) {
99
+ return {
100
+ ...config,
101
+ config_schema_version: version,
102
+ config_authority: canonical ? 'canonical' : 'legacy-import',
103
+ config_migration_required: !canonical || version < CONFIG_SCHEMA_VERSION,
104
+ config_activation: runtimeActivationMetadata(),
105
+ };
106
+ }
107
+
108
+ function freshConfig() {
109
+ const imported = normalizeLegacyGlobalConfig(CREW_GLOBAL_CONFIG_DEFAULTS);
110
+ const normalized = normalizeGlobalConfig({
111
+ ...imported,
112
+ legacy: legacySnapshotFrom(imported),
113
+ config_schema_version: CONFIG_SCHEMA_VERSION,
114
+ });
115
+ return attachReadMetadata(normalized, { version: CONFIG_SCHEMA_VERSION, canonical: true });
116
+ }
117
+
118
+ /**
119
+ * Migrate the former shared-profile Crew Hub default (3080) to the Crew-owned
120
+ * dedicated port on the read path so a fresh or recovered install can never
121
+ * silently reconnect to the official web profile. Everything else is preserved.
122
+ */
123
+ function migrateHubUrl(config, { legacyUrl = CREW_LEGACY_HUB_URL, dedicatedUrl = CREW_DEFAULT_HUB_URL } = {}) {
124
+ if (validObject(config) && config.hub_url === legacyUrl) {
125
+ return { ...config, hub_url: dedicatedUrl, hub_url_migrated_from_legacy: true };
126
+ }
127
+ return config;
128
+ }
129
+
130
+ export function mergeStoredGlobalConfig(stored) {
131
+ if (!validObject(stored)) return freshConfig();
132
+
133
+ // Drop the keys that belonged to the removed vision / image-generation
134
+ // bridge before either branch below, so a canonical config is cleaned as
135
+ // well as a legacy one. Both branches spread the stored object, and the
136
+ // merge deliberately keeps unknown keys, so without this the dead keys
137
+ // would survive every read. Only these known-dead keys go; a genuinely
138
+ // unknown key from a newer release is still preserved.
139
+ stored = Object.fromEntries(
140
+ Object.entries(stored).filter(([key]) => !REMOVED_MULTIMODAL_KEYS.includes(key)),
141
+ );
142
+
143
+ const version = sourceVersion(stored);
144
+ if (configHasCanonicalAuthority(stored)) {
145
+ const merged = { ...CREW_GLOBAL_CONFIG_DEFAULTS, ...stored };
146
+ return attachReadMetadata(migrateHubUrl(normalizeGlobalConfig(merged)), { version, canonical: true });
147
+ }
148
+
149
+ // Legacy files keep the exact v0.2 import semantics. Reading is non-mutating:
150
+ // migration is reported but the disk file is upgraded only on the next
151
+ // explicit save.
152
+ const legacyMerged = migrateHubUrl(legacy.mergeStoredGlobalConfig(stored));
153
+ const normalized = normalizeLegacyGlobalConfig(legacyMerged);
154
+ return attachReadMetadata(normalized, { version, canonical: false });
155
+ }
156
+
157
+ export function readGlobalConfig({ configFile = GLOBAL_CONFIG_FILE } = {}) {
158
+ if (!existsSync(configFile)) return freshConfig();
159
+ return mergeStoredGlobalConfig(readJson(configFile, {}));
160
+ }
161
+
162
+ function stripReadMetadata(config) {
163
+ const next = { ...config };
164
+ delete next.config_authority;
165
+ delete next.config_migration_required;
166
+ delete next.config_activation;
167
+ return next;
168
+ }
169
+
170
+ function cloneCanonical(config) {
171
+ return {
172
+ ...config,
173
+ execution: { ...config.execution },
174
+ worker: {
175
+ ...config.worker,
176
+ model_policy: {
177
+ ...config.worker?.model_policy,
178
+ priority: [...(config.worker?.model_policy?.priority ?? [])],
179
+ escalation_priority: [...(config.worker?.model_policy?.escalation_priority ?? [])],
180
+ escalation: { ...config.worker?.model_policy?.escalation },
181
+ },
182
+ },
183
+ review: {
184
+ ...config.review,
185
+ model_policy: {
186
+ ...config.review?.model_policy,
187
+ priority: [...(config.review?.model_policy?.priority ?? [])],
188
+ },
189
+ },
190
+ legacy: validObject(config.legacy) ? { ...config.legacy } : legacySnapshotFrom(config),
191
+ };
192
+ }
193
+
194
+ function mergeCanonicalPatch(current, patch) {
195
+ const next = cloneCanonical(current);
196
+ if (validObject(patch.execution)) {
197
+ next.execution = { ...next.execution, ...patch.execution };
198
+ if (patch.execution.enabled !== undefined) next.subagents_enabled = Boolean(patch.execution.enabled);
199
+ }
200
+ if (validObject(patch.worker)) {
201
+ next.worker = {
202
+ ...next.worker,
203
+ ...patch.worker,
204
+ model_policy: validObject(patch.worker.model_policy)
205
+ ? {
206
+ ...next.worker?.model_policy,
207
+ ...patch.worker.model_policy,
208
+ escalation: validObject(patch.worker.model_policy.escalation)
209
+ ? { ...next.worker?.model_policy?.escalation, ...patch.worker.model_policy.escalation }
210
+ : next.worker?.model_policy?.escalation,
211
+ }
212
+ : next.worker?.model_policy,
213
+ };
214
+ }
215
+ if (validObject(patch.review)) {
216
+ next.review = {
217
+ ...next.review,
218
+ ...patch.review,
219
+ model_policy: validObject(patch.review.model_policy)
220
+ ? { ...next.review?.model_policy, ...patch.review.model_policy }
221
+ : next.review?.model_policy,
222
+ };
223
+ }
224
+ if (validObject(patch.legacy)) next.legacy = { ...next.legacy, ...patch.legacy };
225
+ // The peak/off-peak schedule replaces as a whole: its parts (windows, weekdays,
226
+ // offset, per-model modes) only make sense together, so a partial merge could
227
+ // pair one operator's windows with another's model list.
228
+ if (validObject(patch.model_schedule)) next.model_schedule = normalizeModelSchedule(patch.model_schedule);
229
+
230
+ // v0.3 still has one provider-mode control. A direct canonical patch is
231
+ // allowed, but it cannot manufacture separate worker/reviewer authorities.
232
+ const providerMode = patch.worker?.provider_mode ?? patch.review?.provider_mode;
233
+ if (providerMode !== undefined) {
234
+ next.worker = { ...next.worker, provider_mode: providerMode };
235
+ next.review = { ...next.review, provider_mode: providerMode };
236
+ next.worker_provider_mode = providerMode;
237
+ }
238
+ return next;
239
+ }
240
+
241
+ function legacyInputFrom(next, patch) {
242
+ const input = { ...next };
243
+ delete input.worker;
244
+ delete input.review;
245
+ delete input.execution;
246
+ delete input.legacy;
247
+ delete input.config_schema_version;
248
+
249
+ // Preset commands own derived role state. Schema-v3 mirrors from the previous
250
+ // preset must not masquerade as explicit overrides during translation.
251
+ if (patch?.collaboration_mode !== undefined || patch?.tier_policy !== undefined) {
252
+ delete input.worker_state;
253
+ delete input.review_state;
254
+ delete input.auto_review;
255
+ delete input.pro_reviews_flash;
256
+ // Non-custom presets own both tier states. Custom deliberately keeps the
257
+ // previous states as its starting point.
258
+ if (input.collaboration_mode !== 'custom') {
259
+ delete input.flash_state;
260
+ delete input.pro_state;
261
+ }
262
+ }
263
+
264
+ // Individual tier-state commands own role eligibility only; the existing
265
+ // role mirrors must not shadow them.
266
+ if (patch?.flash_state !== undefined || patch?.pro_state !== undefined) {
267
+ delete input.worker_state;
268
+ delete input.review_state;
269
+ }
270
+
271
+ // auto_review is itself the new compatibility input. pro_reviews_flash is
272
+ // older, so remove the schema-v3 auto_review mirror when translating it.
273
+ if (patch?.auto_review !== undefined) delete input.review_state;
274
+ if (patch?.pro_reviews_flash !== undefined) {
275
+ delete input.review_state;
276
+ delete input.auto_review;
277
+ }
278
+ return input;
279
+ }
280
+
281
+ function anyPatched(patch, keys) {
282
+ return keys.some((key) => patch?.[key] !== undefined);
283
+ }
284
+
285
+ /**
286
+ * Translate the old flat Settings/API surface into a schema-v3 canonical
287
+ * update without reconstructing unrelated canonical-only fields. This is the
288
+ * key single-authority boundary: a legacy UI command may change what it names,
289
+ * but cannot erase e.g. escalation.max_attempts or future canonical metadata.
290
+ */
291
+ function mergeLegacyPatchIntoCanonical(current, compatibilityView, candidate, patch) {
292
+ const next = cloneCanonical({
293
+ ...compatibilityView,
294
+ execution: current.execution,
295
+ worker: current.worker,
296
+ review: current.review,
297
+ legacy: current.legacy,
298
+ subagents_enabled: current.subagents_enabled,
299
+ main_agent_mode: current.main_agent_mode,
300
+ });
301
+
302
+ if (patch?.subagents_enabled !== undefined) {
303
+ next.subagents_enabled = candidate.subagents_enabled;
304
+ next.execution.enabled = candidate.subagents_enabled;
305
+ }
306
+ if (patch?.main_agent_mode !== undefined) next.main_agent_mode = candidate.main_agent_mode;
307
+
308
+ const executionMap = {
309
+ default_effort: 'default_effort',
310
+ default_timeout_seconds: 'default_timeout_seconds',
311
+ mode: 'mode',
312
+ max_parallel: 'max_parallel',
313
+ isolation: 'isolation',
314
+ };
315
+ for (const [flat, canonical] of Object.entries(executionMap)) {
316
+ if (patch?.[flat] !== undefined) next.execution[canonical] = candidate.execution[canonical];
317
+ }
318
+
319
+ // Presets own strategy + both role states + automatic-review
320
+ // semantics and the canonical legacy compatibility snapshot.
321
+ if (patch?.collaboration_mode !== undefined || patch?.tier_policy !== undefined) {
322
+ next.worker.state = candidate.worker.state;
323
+ next.review.state = candidate.review.state;
324
+ next.worker.model_policy.strategy = candidate.worker.model_policy.strategy;
325
+ next.review.auto_review = candidate.review.auto_review;
326
+ next.legacy = legacySnapshotFrom(candidate);
327
+ }
328
+ // Tier-state controls are narrower: changing eligibility must not reset a
329
+ // canonical model strategy or another future policy dimension. They still
330
+ // update the nested legacy tier-state snapshot because that is their owned
331
+ // compatibility behavior.
332
+ if (patch?.flash_state !== undefined || patch?.pro_state !== undefined) {
333
+ next.worker.state = candidate.worker.state;
334
+ next.review.state = candidate.review.state;
335
+ next.legacy = legacySnapshotFrom(candidate);
336
+ }
337
+ if (patch?.worker_state !== undefined) next.worker.state = candidate.worker.state;
338
+ if (patch?.review_state !== undefined) next.review.state = candidate.review.state;
339
+ if (patch?.auto_review !== undefined || patch?.pro_reviews_flash !== undefined) {
340
+ next.review.auto_review = candidate.review.auto_review;
341
+ next.review.state = candidate.review.state;
342
+ }
343
+
344
+ if (patch?.worker_provider_mode !== undefined) {
345
+ next.worker.provider_mode = candidate.worker.provider_mode;
346
+ next.review.provider_mode = candidate.worker.provider_mode;
347
+ }
348
+
349
+ if (anyPatched(patch, ['flash_model_priority', 'flash_model_priority_configured', 'flash_model_fallback'])) {
350
+ next.worker.model_policy.priority = [...candidate.worker.model_policy.priority];
351
+ next.worker.model_policy.priorityConfigured = candidate.worker.model_policy.priorityConfigured;
352
+ next.worker.model_policy.fallback = candidate.worker.model_policy.fallback;
353
+ }
354
+ if (anyPatched(patch, ['pro_model_priority', 'pro_model_priority_configured', 'pro_model_fallback'])) {
355
+ next.worker.model_policy.escalation_priority = [...candidate.worker.model_policy.escalation_priority];
356
+ next.worker.model_policy.escalation_priority_configured = candidate.worker.model_policy.escalation_priority_configured;
357
+ next.worker.model_policy.fallback = candidate.worker.model_policy.fallback;
358
+ next.review.model_policy.priority = [...candidate.review.model_policy.priority];
359
+ next.review.model_policy.priorityConfigured = candidate.review.model_policy.priorityConfigured;
360
+ next.review.model_policy.fallback = candidate.review.model_policy.fallback;
361
+ }
362
+ if (patch?.escalate_on_failure !== undefined) {
363
+ next.worker.model_policy.escalation.enabled = candidate.worker.model_policy.escalation.enabled;
364
+ }
365
+
366
+ return next;
367
+ }
368
+
369
+ /**
370
+ * Persist one schema-v3 snapshot.
371
+ *
372
+ * Flat fields remain in the JSON as compatibility mirrors for older UI/MCP
373
+ * builds, but schema-v3 readers ignore conflicting mirrors and trust the nested
374
+ * canonical snapshot. Legacy flat patches are accepted as input commands and
375
+ * translated only into the canonical fields they own.
376
+ */
377
+ export function writeGlobalConfig(patch, { configFile = GLOBAL_CONFIG_FILE } = {}) {
378
+ mkdirSync(dirname(configFile), { recursive: true });
379
+ const current = readGlobalConfig({ configFile });
380
+ const wasCanonical = current.config_authority === 'canonical';
381
+ let compatibilityView = stripReadMetadata(current);
382
+
383
+ // Existing public settings surface: accept only known flat keys. Schema and
384
+ // read-only authority metadata are never caller-controlled.
385
+ for (const [key, value] of Object.entries(patch ?? {})) {
386
+ if (value !== undefined && key in legacy.GLOBAL_CONFIG_DEFAULTS) compatibilityView[key] = value;
387
+ }
388
+
389
+ for (const tier of ['flash', 'pro']) {
390
+ const key = `${tier}_model_priority`;
391
+ if (patch?.[key] !== undefined) {
392
+ compatibilityView[key] = normalizeModelPriority(patch[key]);
393
+ compatibilityView[`${tier}_model_priority_configured`] = true;
394
+ }
395
+ }
396
+
397
+ // Deprecated routing fields are input commands only. Keep their input view
398
+ // coherent before the frozen migration translates them.
399
+ if (patch?.tier_policy !== undefined) {
400
+ compatibilityView.tier_policy = patch.tier_policy;
401
+ if (patch.tier_policy === 'flash-only') compatibilityView.collaboration_mode = 'flash-only';
402
+ else if (patch.tier_policy === 'pro-only') compatibilityView.collaboration_mode = 'pro-only';
403
+ else if (compatibilityView.collaboration_mode === 'flash-only' || compatibilityView.collaboration_mode === 'pro-only') compatibilityView.collaboration_mode = 'balanced';
404
+ }
405
+ if (patch?.collaboration_mode !== undefined) {
406
+ if (patch.collaboration_mode === 'flash-only') compatibilityView.tier_policy = 'flash-only';
407
+ else if (patch.collaboration_mode === 'pro-only') compatibilityView.tier_policy = 'pro-only';
408
+ else compatibilityView.tier_policy = 'auto';
409
+ }
410
+
411
+ const candidate = normalizeLegacyGlobalConfig(legacyInputFrom(compatibilityView, patch));
412
+ let normalized = wasCanonical
413
+ ? mergeLegacyPatchIntoCanonical(current, compatibilityView, candidate, patch)
414
+ : { ...candidate, legacy: legacySnapshotFrom(candidate) };
415
+
416
+ // `model_schedule` is canonical-only (it has no flat mirror), so it must open
417
+ // the canonical merge path on its own rather than ride along with worker/review.
418
+ const hasCanonicalPatch = validObject(patch?.worker)
419
+ || validObject(patch?.review)
420
+ || validObject(patch?.execution)
421
+ || validObject(patch?.legacy)
422
+ || validObject(patch?.model_schedule);
423
+ if (hasCanonicalPatch) normalized = mergeCanonicalPatch(normalized, patch);
424
+
425
+ if (!validObject(normalized.legacy)) normalized.legacy = legacySnapshotFrom(candidate);
426
+ normalized = normalizeGlobalConfig({
427
+ ...normalized,
428
+ config_schema_version: CONFIG_SCHEMA_VERSION,
429
+ });
430
+
431
+ const persisted = stripReadMetadata({
432
+ ...normalized,
433
+ config_schema_version: CONFIG_SCHEMA_VERSION,
434
+ });
435
+ writeFileAtomic(configFile, JSON.stringify(persisted, null, 2) + '\n');
436
+ return attachReadMetadata(normalizeGlobalConfig(persisted), {
437
+ version: CONFIG_SCHEMA_VERSION,
438
+ canonical: true,
439
+ });
440
+ }
441
+
442
+ export function getGlobalConfigDiagnostics({ configFile = GLOBAL_CONFIG_FILE } = {}) {
443
+ if (!existsSync(configFile)) {
444
+ return {
445
+ current_schema_version: CONFIG_SCHEMA_VERSION,
446
+ stored_schema_version: null,
447
+ authority: 'canonical',
448
+ migration_required: false,
449
+ canonical_present: true,
450
+ legacy_mirror_conflicts: [],
451
+ };
452
+ }
453
+
454
+ const stored = readJson(configFile, {});
455
+ const version = sourceVersion(stored);
456
+ const canonical = configHasCanonicalAuthority(stored);
457
+ const conflicts = [];
458
+ if (canonical) {
459
+ const effective = normalizeGlobalConfig(stored);
460
+ const mirrorKeys = [
461
+ 'subagents_enabled', 'main_agent_mode', 'default_effort',
462
+ 'default_timeout_seconds', 'mode', 'max_parallel', 'isolation',
463
+ 'collaboration_mode', 'tier_policy', 'flash_state', 'pro_state',
464
+ 'worker_state', 'review_state', 'auto_review', 'worker_provider_mode',
465
+ 'flash_model_priority', 'flash_model_priority_configured',
466
+ 'flash_model_fallback', 'pro_model_priority',
467
+ 'pro_model_priority_configured', 'pro_model_fallback',
468
+ 'escalate_on_failure', 'pro_reviews_flash',
469
+ ];
470
+ for (const key of mirrorKeys) {
471
+ if (!Object.prototype.hasOwnProperty.call(stored, key)) continue;
472
+ if (JSON.stringify(stored[key]) !== JSON.stringify(effective[key])) conflicts.push(key);
473
+ }
474
+ }
475
+
476
+ return {
477
+ current_schema_version: CONFIG_SCHEMA_VERSION,
478
+ stored_schema_version: version,
479
+ authority: canonical ? 'canonical' : 'legacy-import',
480
+ migration_required: !canonical,
481
+ canonical_present: canonical,
482
+ legacy_mirror_conflicts: conflicts,
483
+ };
484
+ }