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