chati-dev 4.4.1 → 4.5.2

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 (82) hide show
  1. package/README.md +29 -27
  2. package/bin/chati.js +235 -1
  3. package/framework/agents/plan/tasks.md +46 -1
  4. package/framework/config.yaml +2 -2
  5. package/framework/constitution.md +13 -16
  6. package/framework/context/governance.md +5 -5
  7. package/framework/context/root.md +5 -5
  8. package/framework/i18n/en.yaml +8 -2
  9. package/framework/i18n/es.yaml +8 -2
  10. package/framework/i18n/fr.yaml +8 -2
  11. package/framework/i18n/pt.yaml +8 -2
  12. package/framework/manifest.json +21 -21
  13. package/framework/manifest.sig +1 -1
  14. package/framework/orchestrator/chati.md +43 -12
  15. package/node_modules/@chati/browser-capability/README.md +10 -0
  16. package/node_modules/@chati/browser-capability/package.json +17 -0
  17. package/node_modules/@chati/browser-capability/src/index.js +165 -0
  18. package/node_modules/@chati/core/package.json +13 -0
  19. package/node_modules/@chati/core/src/index.js +111 -0
  20. package/node_modules/@chati/knowledge-context/package.json +17 -0
  21. package/node_modules/@chati/knowledge-context/src/index.js +202 -0
  22. package/node_modules/@chati/planning/package.json +17 -0
  23. package/node_modules/@chati/planning/src/index.js +367 -0
  24. package/node_modules/@chati/provider-registry/package.json +16 -0
  25. package/node_modules/@chati/provider-registry/src/index.js +148 -0
  26. package/node_modules/@chati/rail/README.md +24 -0
  27. package/node_modules/@chati/rail/package.json +19 -0
  28. package/node_modules/@chati/rail/src/index.js +437 -0
  29. package/node_modules/@chati/release-lane/README.md +24 -0
  30. package/node_modules/@chati/release-lane/package.json +17 -0
  31. package/node_modules/@chati/release-lane/src/index.js +172 -0
  32. package/node_modules/@chati/review-council/package.json +17 -0
  33. package/node_modules/@chati/review-council/src/index.js +264 -0
  34. package/node_modules/@chati/tracking-clickup/README.md +55 -0
  35. package/node_modules/@chati/tracking-clickup/package.json +17 -0
  36. package/node_modules/@chati/tracking-clickup/src/index.js +293 -0
  37. package/package.json +25 -3
  38. package/src/config/ide-configs.js +11 -0
  39. package/src/context/domain-loader.js +1 -1
  40. package/src/dashboard/data-reader.js +1 -1
  41. package/src/executors/runner.js +1 -1
  42. package/src/installer/core.js +14 -13
  43. package/src/installer/provider-overlay.js +8 -15
  44. package/src/installer/scaffold-applier.js +1 -1
  45. package/src/installer/templates.js +12 -25
  46. package/src/installer/validator.js +5 -10
  47. package/src/installer-v2/catalog-client.js +165 -0
  48. package/src/installer-v2/index.js +304 -0
  49. package/src/installer-v2/model-catalog-envelope.json +278 -0
  50. package/src/installer-v2/model-catalog.json +130 -0
  51. package/src/installer-v2/model-catalog.sig +1 -0
  52. package/src/installer-v2/wizard-installation.js +116 -0
  53. package/src/intelligence/registry-manager.js +1 -1
  54. package/src/license/client.js +27 -1
  55. package/src/memory/session-digest.js +1 -1
  56. package/src/merger/yaml-merger.js +1 -1
  57. package/src/orchestrator/browser-runtime.js +25 -0
  58. package/src/orchestrator/cli.js +93 -8
  59. package/src/orchestrator/clickup-projection.js +84 -0
  60. package/src/orchestrator/clickup-runtime.js +13 -0
  61. package/src/orchestrator/knowledge-runtime.js +64 -0
  62. package/src/orchestrator/planning-runtime.js +127 -0
  63. package/src/orchestrator/rail-runtime.js +421 -0
  64. package/src/orchestrator/release-runtime.js +14 -0
  65. package/src/orchestrator/review-runtime.js +74 -0
  66. package/src/orchestrator/runtime-installation-v2.js +57 -0
  67. package/src/orchestrator/session-manager.js +1 -1
  68. package/src/telemetry/config.js +1 -1
  69. package/src/terminal/adapters/grok-adapter.js +16 -0
  70. package/src/terminal/adapters/index.js +1 -0
  71. package/src/terminal/cli-registry.js +20 -5
  72. package/src/terminal/prompt-builder.js +4 -2
  73. package/src/terminal/run-agent.js +3 -0
  74. package/src/terminal/run-parallel.js +21 -10
  75. package/src/terminal/spawner.js +7 -1
  76. package/src/terminal/team-task-list.js +1 -1
  77. package/src/upgrade/checker.js +1 -1
  78. package/src/upgrade/migrator.js +1 -1
  79. package/src/utils/config-parser.js +3 -8
  80. package/src/wizard/i18n.js +10 -4
  81. package/src/wizard/index.js +49 -17
  82. package/src/wizard/questions.js +50 -28
@@ -1,6 +1,6 @@
1
1
  import { existsSync, readFileSync } from 'fs';
2
2
  import { join } from 'path';
3
- import yaml from 'js-yaml';
3
+ import * as yaml from 'js-yaml';
4
4
  import { hashFile } from './file-hasher.js';
5
5
  import { loadManifest } from './manifest.js';
6
6
  import { ADAPTABLE_FILES } from '../config/framework-adapter.js';
@@ -187,28 +187,23 @@ export async function validateInstallation(targetDir) {
187
187
  try {
188
188
  const configContent = readFileSync(configYamlPath, 'utf-8');
189
189
  const enabledProviders = [];
190
- let primaryProvider = null;
191
190
  let currentProvider = null;
192
191
 
193
192
  for (const line of configContent.split('\n')) {
194
193
  const provLine = line.match(/^\s{4}(\w+):$/);
195
- if (provLine && ['claude', 'gemini', 'codex'].includes(provLine[1])) {
194
+ if (provLine && ['claude', 'gemini', 'codex', 'grok'].includes(provLine[1])) {
196
195
  currentProvider = provLine[1];
197
196
  }
198
197
  if (currentProvider && line.includes('enabled: true')) {
199
198
  enabledProviders.push(currentProvider);
200
199
  }
201
- if (currentProvider && line.includes('primary: true')) {
202
- primaryProvider = currentProvider;
203
- }
204
200
  }
205
201
 
206
- // Validate overlay completeness for each secondary provider
207
- if (enabledProviders.length > 1 && primaryProvider) {
208
- const secondaryProviders = enabledProviders.filter(p => p !== primaryProvider);
202
+ // Validate overlay completeness for every enabled provider.
203
+ if (enabledProviders.length > 0) {
209
204
  const overlayDetails = [];
210
205
 
211
- for (const provider of secondaryProviders) {
206
+ for (const provider of enabledProviders) {
212
207
  let present = 0;
213
208
  let missing = 0;
214
209
  const missingFiles = [];
@@ -0,0 +1,165 @@
1
+ import { createPublicKey, verify as cryptoVerify } from 'node:crypto';
2
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
3
+ import { dirname, join } from 'node:path';
4
+ import { isIP } from 'node:net';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { canonicalSerialize, ContractError } from '@chati/core';
7
+ import { validateCapabilitySnapshot } from '@chati/provider-registry';
8
+
9
+ const moduleDir = dirname(fileURLToPath(import.meta.url));
10
+
11
+ export const DEFAULT_CATALOG_URL = 'https://raw.githubusercontent.com/Chati-dev/Chati.dev/main/packages/chati-dev/src/installer-v2/model-catalog-envelope.json';
12
+ export const CATALOG_CACHE_PATH = '.chati/v2/cache/model-catalog.json';
13
+ export const BUNDLED_CATALOG_PATH = join(moduleDir, 'model-catalog.json');
14
+ export const BUNDLED_CATALOG_SIGNATURE_PATH = join(moduleDir, 'model-catalog.sig');
15
+ const PUBLIC_KEY_PATH = join(moduleDir, '..', 'installer', 'signing-public-key.pem');
16
+
17
+ function catalogError(code, message, details = {}) {
18
+ return new ContractError(code, message, details);
19
+ }
20
+
21
+ function parseSignedPayload(value) {
22
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
23
+ throw catalogError('INVALID_SIGNED_CATALOG', 'signed catalog payload must be an object');
24
+ }
25
+ if (!value.catalog || typeof value.signature !== 'string' || !value.signature.trim()) {
26
+ throw catalogError('INVALID_SIGNED_CATALOG', 'signed catalog payload requires catalog and signature');
27
+ }
28
+ return { catalog: value.catalog, signature: value.signature.trim() };
29
+ }
30
+
31
+ function readPublicKey(publicKeyPem) {
32
+ const source = publicKeyPem ?? readFileSync(PUBLIC_KEY_PATH, 'utf8');
33
+ return createPublicKey(source);
34
+ }
35
+
36
+ export function assertCatalogUrl(value) {
37
+ let url;
38
+ try { url = new URL(value); } catch { throw catalogError('CATALOG_URL_INVALID', 'catalog URL must be an absolute HTTPS URL'); }
39
+ if (url.protocol !== 'https:' || url.username || url.password) throw catalogError('CATALOG_URL_INVALID', 'catalog URL must use HTTPS without embedded credentials');
40
+ const hostname = url.hostname.toLowerCase().replace(/^\[|\]$/g, '');
41
+ const ipKind = isIP(hostname);
42
+ const blockedIpv4 = ipKind === 4 && (/^10\./.test(hostname)
43
+ || /^127\./.test(hostname)
44
+ || /^169\.254\./.test(hostname)
45
+ || /^192\.168\./.test(hostname)
46
+ || /^172\.(1[6-9]|2\d|3[01])\./.test(hostname)
47
+ || /^0\./.test(hostname));
48
+ const blockedIpv6 = ipKind === 6 && (hostname === '::1' || hostname === '::' || /^(fc|fd|fe8|fe9|fea|feb)/.test(hostname));
49
+ if (hostname === 'localhost' || hostname.endsWith('.localhost') || blockedIpv4 || blockedIpv6) {
50
+ throw catalogError('CATALOG_URL_FORBIDDEN', 'catalog URL may not target a loopback, link-local, or private host');
51
+ }
52
+ return url.toString();
53
+ }
54
+
55
+ export function verifySignedCapabilityCatalog(payload, { clock = () => new Date(), publicKeyPem } = {}) {
56
+ const { catalog, signature } = parseSignedPayload(payload);
57
+ const verified = cryptoVerify(
58
+ null,
59
+ Buffer.from(canonicalSerialize(catalog)),
60
+ readPublicKey(publicKeyPem),
61
+ Buffer.from(signature, 'base64'),
62
+ );
63
+ if (!verified) throw catalogError('CATALOG_SIGNATURE_INVALID', 'model catalog signature is invalid');
64
+ return validateCapabilitySnapshot(catalog, { clock });
65
+ }
66
+
67
+ function readSignedCatalogFile(path) {
68
+ return parseSignedPayload(JSON.parse(readFileSync(path, 'utf8')));
69
+ }
70
+
71
+ function writeCache(projectDir, payload) {
72
+ const path = join(projectDir, CATALOG_CACHE_PATH);
73
+ mkdirSync(dirname(path), { recursive: true });
74
+ const temporary = `${path}.tmp-${process.pid}`;
75
+ writeFileSync(temporary, `${JSON.stringify(payload, null, 2)}\n`, { encoding: 'utf8', flag: 'wx' });
76
+ renameSync(temporary, path);
77
+ return CATALOG_CACHE_PATH;
78
+ }
79
+
80
+ function readBundledPayload() {
81
+ return {
82
+ catalog: JSON.parse(readFileSync(BUNDLED_CATALOG_PATH, 'utf8')),
83
+ signature: readFileSync(BUNDLED_CATALOG_SIGNATURE_PATH, 'utf8').trim(),
84
+ };
85
+ }
86
+
87
+ function catalogPublishedAt(catalog) {
88
+ const parsed = catalog.published_at ? Date.parse(catalog.published_at) : 0;
89
+ return Number.isNaN(parsed) ? 0 : parsed;
90
+ }
91
+
92
+ const SOURCE_PRIORITY = Object.freeze({ remote: 3, cache: 2, bundled: 1 });
93
+
94
+ /**
95
+ * Resolves every available signed CHATI model catalog and selects the newest
96
+ * published snapshot. Remote wins ties, then cache, then the bundled release.
97
+ * Vendor APIs and local binary inference stay outside this trust boundary.
98
+ */
99
+ export async function resolveCapabilityCatalog({
100
+ projectDir,
101
+ catalogUrl = process.env.CHATI_MODEL_CATALOG_URL || DEFAULT_CATALOG_URL,
102
+ fetchImpl = globalThis.fetch,
103
+ clock = () => new Date(),
104
+ publicKeyPem,
105
+ timeoutMs = 5000,
106
+ } = {}) {
107
+ const failures = [];
108
+ const candidates = [];
109
+
110
+ if (catalogUrl && typeof fetchImpl === 'function') {
111
+ try {
112
+ const safeCatalogUrl = assertCatalogUrl(catalogUrl);
113
+ const response = await fetchImpl(safeCatalogUrl, {
114
+ headers: { accept: 'application/json' },
115
+ signal: AbortSignal.timeout(timeoutMs),
116
+ });
117
+ if (!response.ok) throw catalogError('CATALOG_HTTP_ERROR', `catalog endpoint returned HTTP ${response.status}`);
118
+ const payload = parseSignedPayload(await response.json());
119
+ const catalog = verifySignedCapabilityCatalog(payload, { clock, publicKeyPem });
120
+ candidates.push({ source: 'remote', source_url: safeCatalogUrl, payload, catalog });
121
+ } catch (error) {
122
+ failures.push({ source: 'remote', code: error.code || error.name || 'CATALOG_FETCH_FAILED' });
123
+ }
124
+ }
125
+
126
+ if (projectDir) {
127
+ const cachePath = join(projectDir, CATALOG_CACHE_PATH);
128
+ if (existsSync(cachePath)) {
129
+ try {
130
+ const payload = readSignedCatalogFile(cachePath);
131
+ const catalog = verifySignedCapabilityCatalog(payload, { clock, publicKeyPem });
132
+ candidates.push({ source: 'cache', cache_path: CATALOG_CACHE_PATH, payload, catalog });
133
+ } catch (error) {
134
+ failures.push({ source: 'cache', code: error.code || error.name || 'CATALOG_CACHE_INVALID' });
135
+ }
136
+ }
137
+ }
138
+
139
+ try {
140
+ const payload = readBundledPayload();
141
+ const catalog = verifySignedCapabilityCatalog(payload, { clock, publicKeyPem });
142
+ candidates.push({ source: 'bundled', cache_path: null, payload, catalog });
143
+ } catch (error) {
144
+ failures.push({ source: 'bundled', code: error.code || error.name || 'CATALOG_BUNDLE_INVALID' });
145
+ }
146
+
147
+ if (candidates.length === 0) {
148
+ throw catalogError('CATALOG_UNAVAILABLE', 'no fresh, signed CHATI model catalog is available', { failures });
149
+ }
150
+
151
+ candidates.sort((left, right) =>
152
+ catalogPublishedAt(right.catalog) - catalogPublishedAt(left.catalog)
153
+ || SOURCE_PRIORITY[right.source] - SOURCE_PRIORITY[left.source]
154
+ );
155
+ const selected = candidates[0];
156
+ const cache_path = selected.source === 'remote' && projectDir
157
+ ? writeCache(projectDir, selected.payload)
158
+ : (selected.cache_path ?? null);
159
+ return Object.freeze({
160
+ source: selected.source,
161
+ ...(selected.source_url ? { source_url: selected.source_url } : {}),
162
+ cache_path,
163
+ catalog: selected.catalog,
164
+ });
165
+ }
@@ -0,0 +1,304 @@
1
+ /**
2
+ * Installer v2 is deliberately a planner, not an installer.
3
+ *
4
+ * It creates one provider-neutral installation artifact, inspects a target for
5
+ * legacy v1 markers, and returns a migration/rollback plan. Artifact writes
6
+ * are explicit through installV2 or authorized migrateV2. It never invokes a
7
+ * provider CLI or reads credentials.
8
+ */
9
+ import { existsSync, mkdirSync, writeFileSync, renameSync, readFileSync, copyFileSync } from 'node:fs';
10
+ import { dirname, join, relative, resolve, sep } from 'node:path';
11
+ import { canonicalize, canonicalSerialize, sha256, ContractError } from '@chati/core';
12
+ import { validateInstallation } from '@chati/provider-registry';
13
+
14
+ export const INSTALLATION_SCHEMA_VERSION = 2;
15
+ export const INSTALLATION_ARTIFACT_PATH = '.chati/v2/installation.json';
16
+
17
+ const V1_MARKERS = Object.freeze([
18
+ { provider_id: 'anthropic', harness_id: 'claude', path: '.claude/settings.json' },
19
+ { provider_id: 'anthropic', harness_id: 'claude', path: 'CLAUDE.local.md' },
20
+ { provider_id: 'openai', harness_id: 'codex', path: '.codex/config.toml' },
21
+ { provider_id: 'xai', harness_id: 'grok', path: '.grok/config.json' },
22
+ { provider_id: null, harness_id: null, path: '.chati/session.yaml' },
23
+ ]);
24
+
25
+ const PROVIDER_SPECIFIC_ROOT_FILES = new Set([
26
+ 'claude.md', 'claude.local.md', 'codex.md', 'codex.local.md', 'grok.md', 'grok.local.md',
27
+ '.claude.lock', '.codex.lock', '.grok.lock',
28
+ ]);
29
+
30
+ function assertObject(value, code, label) {
31
+ if (!value || typeof value !== 'object' || Array.isArray(value)) throw new ContractError(code, `${label} must be an object`);
32
+ }
33
+
34
+ function assertString(value, code, label) {
35
+ if (typeof value !== 'string' || value.trim() === '') throw new ContractError(code, `${label} must be a non-empty string`);
36
+ }
37
+
38
+ function readClock(clock) {
39
+ if (typeof clock !== 'function') throw new ContractError('MISSING_CLOCK', 'a deterministic clock function is required');
40
+ const result = clock();
41
+ const date = result instanceof Date ? result : new Date(result);
42
+ if (Number.isNaN(date.getTime())) throw new ContractError('INVALID_CLOCK', 'clock must return a valid timestamp');
43
+ return date.toISOString();
44
+ }
45
+
46
+ function ensureRelativePath(path) {
47
+ assertString(path, 'INVALID_MANAGED_PATH', 'managed path');
48
+ const normalized = path.replaceAll('\\', '/');
49
+ const root = normalized.split('/')[0];
50
+ if (normalized.startsWith('/') || normalized.split('/').includes('..')) {
51
+ throw new ContractError('INVALID_MANAGED_PATH', 'managed path must remain inside the project');
52
+ }
53
+ if (['.claude', '.codex', '.grok'].includes(root)) {
54
+ throw new ContractError('PROVIDER_SPECIFIC_MANAGED_PATH', 'installer v2 may not manage provider-specific configuration paths');
55
+ }
56
+ if (PROVIDER_SPECIFIC_ROOT_FILES.has(root.toLowerCase())) {
57
+ throw new ContractError('PROVIDER_SPECIFIC_MANAGED_PATH', 'installer v2 may not manage provider-specific root markers or locks');
58
+ }
59
+ return normalized;
60
+ }
61
+
62
+ function normalizeBrainReadCapability(capability) {
63
+ if (capability === undefined || capability === null || capability === false) return { status: 'not-configured' };
64
+ assertObject(capability, 'INVALID_BRAIN_CAPABILITY', 'brain_read_capability');
65
+ if (capability.status === 'not-configured') return { status: 'not-configured' };
66
+ if (capability.mode !== 'read-only') throw new ContractError('BRAIN_WRITE_FORBIDDEN', 'installer v2 permits only a read-only Brain capability');
67
+ assertString(capability.policy_ref, 'INVALID_BRAIN_CAPABILITY', 'brain_read_capability.policy_ref');
68
+ return { status: 'configured', mode: 'read-only', policy_ref: capability.policy_ref };
69
+ }
70
+
71
+ function artifactCore(input, createdAt) {
72
+ assertObject(input, 'INVALID_INSTALLATION', 'installation input');
73
+ assertObject(input.installation, 'INVALID_INSTALLATION', 'installation');
74
+ assertObject(input.capability_snapshot, 'INVALID_CATALOG', 'capability_snapshot');
75
+ assertString(input.project_id, 'INVALID_INSTALLATION', 'project_id');
76
+ const installation = validateInstallation(input.installation, input.capability_snapshot, { clock: () => createdAt });
77
+ const brain_read_capability = normalizeBrainReadCapability(input.brain_read_capability);
78
+ const managed_paths = (input.managed_paths ?? [INSTALLATION_ARTIFACT_PATH]).map(ensureRelativePath);
79
+ if (!managed_paths.includes(INSTALLATION_ARTIFACT_PATH)) managed_paths.unshift(INSTALLATION_ARTIFACT_PATH);
80
+ if (new Set(managed_paths).size !== managed_paths.length) throw new ContractError('DUPLICATE_MANAGED_PATH', 'managed_paths must be unique');
81
+ const selectedModelKeys = new Set(installation.enabled_providers.flatMap((binding) =>
82
+ binding.allowed_models.map((model_id) => `${binding.provider_id}:${model_id}`),
83
+ ));
84
+ const capability_snapshot = {
85
+ // This is a projection, not a catalog copy. Keep only attributes needed by
86
+ // the installation contract and only models explicitly selected by a binding.
87
+ snapshot_id: input.capability_snapshot.snapshot_id,
88
+ expires_at: input.capability_snapshot.expires_at,
89
+ models: input.capability_snapshot.models
90
+ .filter((model) => selectedModelKeys.has(`${model.provider_id}:${model.model_id}`))
91
+ .map(({ provider_id, model_id, actions, adjudication_priority, highest_reasoning_configuration, routing_priority, tier, natural_role }) => ({
92
+ provider_id,
93
+ model_id,
94
+ actions,
95
+ ...(adjudication_priority !== undefined ? { adjudication_priority } : {}),
96
+ ...(highest_reasoning_configuration !== undefined ? { highest_reasoning_configuration } : {}),
97
+ ...(routing_priority !== undefined ? { routing_priority } : {}),
98
+ ...(tier !== undefined ? { tier } : {}),
99
+ ...(natural_role !== undefined ? { natural_role } : {}),
100
+ })),
101
+ };
102
+ return {
103
+ schema_version: INSTALLATION_SCHEMA_VERSION,
104
+ artifact_type: 'chati-installation',
105
+ project_id: input.project_id,
106
+ created_at: createdAt,
107
+ installation,
108
+ // An artifact exposes no capability information for providers not selected
109
+ // during installation. The full catalog remains an input, never an output.
110
+ capability_snapshot,
111
+ brain_read_capability,
112
+ managed_paths,
113
+ };
114
+ }
115
+
116
+ /** Creates a deterministic provider-neutral artifact. It never installs it. */
117
+ export function createInstallationArtifact(input, { clock = () => new Date(), artifactId } = {}) {
118
+ const core = artifactCore(input, readClock(clock));
119
+ const digest = sha256(core);
120
+ const installation_artifact_id = artifactId ?? `install-v2-${digest.slice(0, 16)}`;
121
+ assertString(installation_artifact_id, 'INVALID_INSTALLATION', 'installation_artifact_id');
122
+ return Object.freeze(canonicalize({ ...core, installation_artifact_id, digest }));
123
+ }
124
+
125
+ /** Validates an artifact without inspecting provider-specific configuration files. */
126
+ export function doctorV2(artifact, { clock = () => new Date() } = {}) {
127
+ const checks = [];
128
+ try {
129
+ assertObject(artifact, 'INVALID_ARTIFACT', 'artifact');
130
+ if (artifact.schema_version !== INSTALLATION_SCHEMA_VERSION || artifact.artifact_type !== 'chati-installation') {
131
+ throw new ContractError('UNSUPPORTED_INSTALLATION_ARTIFACT', 'expected a chati installation artifact v2');
132
+ }
133
+ const { digest, installation_artifact_id, ...core } = artifact;
134
+ assertString(installation_artifact_id, 'INVALID_ARTIFACT', 'installation_artifact_id');
135
+ if (sha256(core) !== digest) throw new ContractError('INSTALLATION_ARTIFACT_DRIFT', 'installation artifact digest does not match its content');
136
+ validateInstallation(artifact.installation, artifact.capability_snapshot, { clock: () => readClock(clock) });
137
+ normalizeBrainReadCapability(artifact.brain_read_capability);
138
+ if (!Array.isArray(artifact.managed_paths) || !artifact.managed_paths.every((path) => ensureRelativePath(path))) {
139
+ throw new ContractError('INVALID_MANAGED_PATH', 'managed_paths must be valid project-relative paths');
140
+ }
141
+ checks.push({ name: 'artifact', status: 'pass', message: 'artifact schema and digest are valid' });
142
+ checks.push({ name: 'selected-bindings', status: 'pass', message: 'only explicitly selected provider/harness bindings are present' });
143
+ checks.push({ name: 'brain-capability', status: 'pass', message: 'Brain capability is absent or read-only' });
144
+ } catch (error) {
145
+ checks.push({ name: 'artifact', status: 'fail', code: error.code ?? 'INVALID_ARTIFACT', message: error.message });
146
+ }
147
+ return Object.freeze({ schema_version: 1, doctor: 'chati-installation-v2', passed: checks.every((check) => check.status === 'pass'), checks });
148
+ }
149
+
150
+ /** Read-only detector for known v1 footprints. Marker presence never enables a provider. */
151
+ export function detectV1Installation(projectDir, { exists = existsSync } = {}) {
152
+ assertString(projectDir, 'INVALID_PROJECT_DIR', 'projectDir');
153
+ if (typeof exists !== 'function') throw new ContractError('INVALID_DETECTOR', 'exists must be a function');
154
+ const markers = V1_MARKERS.filter((marker) => exists(join(projectDir, marker.path))).map((marker) => ({ ...marker }));
155
+ const bindings = markers
156
+ .filter((marker) => marker.provider_id)
157
+ .map(({ provider_id, harness_id }) => ({ provider_id, harness_id }))
158
+ .filter((binding, index, all) => all.findIndex((entry) => entry.provider_id === binding.provider_id && entry.harness_id === binding.harness_id) === index);
159
+ return Object.freeze({ detector_version: 1, detected: markers.length > 0, markers, detected_bindings: bindings });
160
+ }
161
+
162
+ /** Builds a backup plan only. The caller must separately authorize and execute any write. */
163
+ export function createBackupPlan(projectDir, detector, artifact) {
164
+ assertString(projectDir, 'INVALID_PROJECT_DIR', 'projectDir');
165
+ assertObject(detector, 'INVALID_DETECTOR', 'detector');
166
+ assertObject(artifact, 'INVALID_ARTIFACT', 'artifact');
167
+ const backup_id = `backup-v2-${artifact.digest.slice(0, 16)}`;
168
+ const entries = detector.markers.map((marker) => ({ source_path: marker.path, backup_path: `.chati/v2/backups/${backup_id}/${marker.path}` }));
169
+ return Object.freeze(canonicalize({ schema_version: 1, backup_id, project_dir: resolve(projectDir), entries, write_required: false }));
170
+ }
171
+
172
+ /** Produces the inverse of a future authorized migration, without performing either operation. */
173
+ export function createRollbackPlan(artifact, backupPlan) {
174
+ assertObject(artifact, 'INVALID_ARTIFACT', 'artifact');
175
+ assertObject(backupPlan, 'INVALID_BACKUP_PLAN', 'backupPlan');
176
+ return Object.freeze(canonicalize({
177
+ schema_version: 1,
178
+ rollback_id: `rollback-v2-${artifact.digest.slice(0, 16)}`,
179
+ installation_artifact_id: artifact.installation_artifact_id,
180
+ backup_id: backupPlan.backup_id,
181
+ restore_entries: backupPlan.entries,
182
+ remove_created_paths: artifact.managed_paths,
183
+ requires_explicit_authorization: true,
184
+ write_required: false,
185
+ }));
186
+ }
187
+
188
+ /**
189
+ * Read-only migration preview. It does not write a v2 artifact or back up v1.
190
+ * Re-running with the same inputs returns the same plans and does not duplicate
191
+ * provider bindings or managed paths.
192
+ */
193
+ export function dryRunV2(input, { projectDir, clock = () => new Date(), exists = existsSync } = {}) {
194
+ assertString(projectDir, 'INVALID_PROJECT_DIR', 'projectDir');
195
+ const artifact = createInstallationArtifact(input, { clock });
196
+ const doctor = doctorV2(artifact, { clock });
197
+ const detector = detectV1Installation(projectDir, { exists });
198
+ const backup_plan = createBackupPlan(projectDir, detector, artifact);
199
+ const rollback_plan = createRollbackPlan(artifact, backup_plan);
200
+ const configured = new Set(artifact.installation.enabled_providers.map(({ provider_id, harness_id }) => `${provider_id}:${harness_id}`));
201
+ const incompatibilities = detector.detected_bindings
202
+ .filter(({ provider_id, harness_id }) => !configured.has(`${provider_id}:${harness_id}`))
203
+ .map(({ provider_id, harness_id }) => ({ code: 'V1_BINDING_NOT_SELECTED', provider_id, harness_id, message: 'legacy marker is not a selected v2 binding' }));
204
+ const pathOutsideProject = artifact.managed_paths.some((managedPath) => relative(resolve(projectDir), resolve(projectDir, managedPath)).split(sep).includes('..'));
205
+ if (pathOutsideProject) throw new ContractError('INVALID_MANAGED_PATH', 'managed path resolves outside project');
206
+ return Object.freeze(canonicalize({ schema_version: 1, mode: 'dry-run', writes_performed: false, artifact, doctor, detector, backup_plan, rollback_plan, incompatibilities }));
207
+ }
208
+
209
+ /** Explicit v2 installation. It writes only the provider-neutral artifact path. */
210
+ export function installV2(input, { projectDir, clock = () => new Date(), exists = existsSync } = {}) {
211
+ const report = dryRunV2(input, { projectDir, clock, exists });
212
+ const artifactPath = join(projectDir, INSTALLATION_ARTIFACT_PATH);
213
+ const directory = join(projectDir, '.chati', 'v2');
214
+ mkdirSync(directory, { recursive: true });
215
+ const serialized = `${canonicalSerialize(report.artifact)}\n`;
216
+ if (existsSync(artifactPath)) {
217
+ const existing = readFileSync(artifactPath, 'utf8');
218
+ if (existing === serialized) return Object.freeze(canonicalize({ ...report, mode: 'installed', writes_performed: false, artifact_path: INSTALLATION_ARTIFACT_PATH }));
219
+ throw new ContractError('INSTALLATION_ARTIFACT_EXISTS', 'existing v2 artifact differs; use an explicit migration or rollback flow');
220
+ }
221
+ const temporary = `${artifactPath}.tmp-${process.pid}`;
222
+ writeFileSync(temporary, serialized, { encoding: 'utf8', flag: 'wx' });
223
+ renameSync(temporary, artifactPath);
224
+ return Object.freeze(canonicalize({ ...report, mode: 'installed', writes_performed: true, artifact_path: INSTALLATION_ARTIFACT_PATH }));
225
+ }
226
+
227
+ /**
228
+ * Explicitly replaces an existing v2 artifact after preserving its exact
229
+ * bytes in the project-local backup store. Provider-specific files are never
230
+ * read or rewritten by this operation.
231
+ */
232
+ export function reconfigureV2(input, { projectDir, clock = () => new Date(), exists = existsSync } = {}) {
233
+ const report = dryRunV2(input, { projectDir, clock, exists });
234
+ const artifactPath = join(projectDir, INSTALLATION_ARTIFACT_PATH);
235
+ if (!existsSync(artifactPath)) return installV2(input, { projectDir, clock, exists });
236
+ const existing = readFileSync(artifactPath, 'utf8');
237
+ let existingArtifact;
238
+ try { existingArtifact = JSON.parse(existing); } catch (error) {
239
+ throw new ContractError('EXISTING_INSTALLATION_CORRUPT', `existing v2 artifact is not valid JSON: ${error.message}`);
240
+ }
241
+ if (existingArtifact?.schema_version !== INSTALLATION_SCHEMA_VERSION || existingArtifact?.artifact_type !== 'chati-installation') {
242
+ throw new ContractError('EXISTING_INSTALLATION_CORRUPT', 'existing v2 artifact has an unsupported schema');
243
+ }
244
+ const backupId = `reconfigure-${sha256({ previous: existingArtifact.digest, next: report.artifact.digest }).slice(0, 20)}`;
245
+ const backupPath = join(projectDir, '.chati', 'v2', 'backups', backupId, 'installation.json');
246
+ mkdirSync(dirname(backupPath), { recursive: true });
247
+ if (existsSync(backupPath) && readFileSync(backupPath, 'utf8') !== existing) {
248
+ throw new ContractError('RECONFIGURE_BACKUP_CONFLICT', 'reconfiguration backup path contains different material');
249
+ }
250
+ if (!existsSync(backupPath)) writeFileSync(backupPath, existing, { encoding: 'utf8', flag: 'wx', mode: 0o600 });
251
+ const serialized = `${canonicalSerialize(report.artifact)}\n`;
252
+ const temporary = `${artifactPath}.tmp-${process.pid}`;
253
+ writeFileSync(temporary, serialized, { encoding: 'utf8', flag: 'wx', mode: 0o600 });
254
+ renameSync(temporary, artifactPath);
255
+ return Object.freeze(canonicalize({
256
+ ...report,
257
+ mode: 'reconfigured',
258
+ writes_performed: true,
259
+ artifact_path: INSTALLATION_ARTIFACT_PATH,
260
+ backup_path: relative(projectDir, backupPath).replaceAll('\\', '/'),
261
+ provider_specific_files_rewritten: false,
262
+ }));
263
+ }
264
+
265
+ /**
266
+ * Performs the narrow, explicit v4 to v2 activation. It backs up detected
267
+ * legacy markers, writes the provider-neutral artifact, and never rewrites a
268
+ * provider-specific configuration file.
269
+ */
270
+ export function migrateV2(input, { projectDir, authorize = false, clock = () => new Date(), exists = existsSync } = {}) {
271
+ const preview = dryRunV2(input, { projectDir, clock, exists });
272
+ if (authorize !== true) {
273
+ return Object.freeze(canonicalize({ ...preview, mode: 'migration-preview', writes_performed: false, requires_explicit_authorization: true }));
274
+ }
275
+
276
+ const artifactPath = join(projectDir, INSTALLATION_ARTIFACT_PATH);
277
+ if (existsSync(artifactPath)) {
278
+ const expected = `${canonicalSerialize(preview.artifact)}\n`;
279
+ if (readFileSync(artifactPath, 'utf8') !== expected) {
280
+ throw new ContractError('INSTALLATION_ARTIFACT_EXISTS', 'existing v2 artifact differs; use an explicit rollback flow');
281
+ }
282
+ }
283
+
284
+ const backed_up = [];
285
+ for (const entry of preview.backup_plan.entries) {
286
+ const source = join(projectDir, entry.source_path);
287
+ const destination = join(projectDir, entry.backup_path);
288
+ if (!existsSync(source) || existsSync(destination)) continue;
289
+ mkdirSync(dirname(destination), { recursive: true });
290
+ copyFileSync(source, destination, 0);
291
+ backed_up.push(entry);
292
+ }
293
+ const installed = installV2(input, { projectDir, clock, exists });
294
+ return Object.freeze(canonicalize({
295
+ ...preview,
296
+ mode: 'migrated',
297
+ writes_performed: installed.writes_performed || backed_up.length > 0,
298
+ backed_up,
299
+ artifact_path: installed.artifact_path,
300
+ provider_specific_files_rewritten: false,
301
+ }));
302
+ }
303
+
304
+ export { canonicalSerialize };