chati-dev 4.5.7 → 4.5.9

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 (37) hide show
  1. package/framework/agents/build/dev.md +1 -1
  2. package/framework/agents/deploy/devops.md +1 -1
  3. package/framework/agents/discover/brief.md +1 -1
  4. package/framework/agents/discover/brownfield-wu.md +1 -1
  5. package/framework/agents/discover/greenfield-wu.md +1 -1
  6. package/framework/agents/plan/architect-data-engineer.md +1 -1
  7. package/framework/agents/plan/architect-system.md +1 -1
  8. package/framework/agents/plan/architect.md +1 -1
  9. package/framework/agents/plan/detail.md +1 -1
  10. package/framework/agents/plan/phases.md +1 -1
  11. package/framework/agents/plan/tasks.md +1 -1
  12. package/framework/agents/plan/ux-brand-architect.md +1 -1
  13. package/framework/agents/plan/ux-component-engineer.md +1 -1
  14. package/framework/agents/plan/ux-researcher.md +1 -1
  15. package/framework/agents/plan/ux.md +1 -1
  16. package/framework/agents/quality/qa-implementation.md +1 -1
  17. package/framework/agents/quality/qa-planning.md +1 -1
  18. package/framework/agents/quality/qa-visual.md +1 -1
  19. package/framework/agents/shared/visualizer.md +1 -1
  20. package/framework/config.yaml +2 -2
  21. package/framework/context/root.md +1 -1
  22. package/framework/data/entity-registry.yaml +1 -1
  23. package/framework/hooks/license-guard.js +19 -0
  24. package/framework/manifest.json +53 -48
  25. package/framework/manifest.sig +1 -1
  26. package/framework/orchestrator/chati-router.js +177 -71
  27. package/framework/orchestrator/chati.md +1 -1
  28. package/framework/package.json +3 -0
  29. package/package.json +1 -1
  30. package/src/config/claude-settings-generator.js +6 -6
  31. package/src/config/context-file-generator.js +2 -2
  32. package/src/config/ide-configs.js +3 -3
  33. package/src/config/mcp-configs.js +80 -10
  34. package/src/installer/core.js +60 -34
  35. package/src/installer/path-replacement.js +13 -0
  36. package/src/installer/provider-overlay.js +3 -1
  37. package/src/installer-v2/index.js +55 -0
@@ -1,15 +1,16 @@
1
1
  import { mkdirSync, writeFileSync, copyFileSync, existsSync, readFileSync, readdirSync, statSync } from 'fs';
2
2
  import { execFileSync } from 'child_process';
3
- import { join, dirname, basename } from 'path';
3
+ import { join, dirname } from 'path';
4
4
  import { fileURLToPath } from 'url';
5
5
  import { createRequire } from 'module';
6
6
  import { IDE_CONFIGS, IDE_TO_PROVIDER } from '../config/ide-configs.js';
7
- import { generateClaudeMCPConfig } from '../config/mcp-configs.js';
7
+ import { mergeClaudeMCPConfig, upsertManagedMcpToml } from '../config/mcp-configs.js';
8
8
  import { generateSessionYaml, generateConfigYaml, generateClaudeMd, generateClaudeLocalMd, generateCodexSkill, generateGeminiRouter, generateGrokRouter, generateGeminiSessionLock, generateAgentsOverrideMd, generateCodexConstitutionGuardRules, generateCodexReadProtectionRules } from './templates.js';
9
9
  import { generateContextFiles } from '../config/context-file-generator.js';
10
10
  import { adaptFrameworkFile, ADAPTABLE_FILES } from '../config/framework-adapter.js';
11
11
  import { generateClaudeSettings } from '../config/claude-settings-generator.js';
12
12
  import { generateProviderOverlays } from './provider-overlay.js';
13
+ import { applyInstalledPathReplacement } from './path-replacement.js';
13
14
  import { verifyManifest } from './manifest.js';
14
15
 
15
16
  const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -192,7 +193,7 @@ export async function installFramework(config) {
192
193
  if (existsSync(src)) {
193
194
  // Apply path replacement so rules reference .chati.dev/ not chati.dev/
194
195
  let content = readFileSync(src, 'utf-8');
195
- content = applyPathReplacement(content);
196
+ content = applyInstalledPathReplacement(content);
196
197
  writeFileSync(join(claudeRulesDir, file), content, 'utf-8');
197
198
  }
198
199
  }
@@ -269,6 +270,7 @@ export const FRAMEWORK_DIRS_TO_COPY = [
269
270
  */
270
271
  const FRAMEWORK_ROOT_FILES = [
271
272
  'constitution.md',
273
+ 'package.json',
272
274
  ];
273
275
 
274
276
  /**
@@ -287,20 +289,6 @@ const TEXT_EXTENSIONS = new Set(['.md', '.js', '.yaml', '.yml', '.json', '.html'
287
289
  * Order matters: replace `chati.dev/artifacts/` FIRST (→ `artifacts/`),
288
290
  * then `chati.dev/` (→ `.chati.dev/`). Reversing would produce `.chati.dev/artifacts/`.
289
291
  */
290
- function applyPathReplacement(content) {
291
- return content
292
- // Step 1: artifacts at root (must be before generic replace)
293
- .replace(/chati\.dev\/artifacts\//g, 'artifacts/')
294
- // Step 2: path strings with slash — but NOT if already prefixed with dot
295
- // (negative lookbehind for any char that would create `.chati.dev` already)
296
- .replace(/(?<!\.)chati\.dev\//g, '.chati.dev/')
297
- // Step 3: quoted dir name in JS join() calls (WITHOUT slash)
298
- // Only match 'chati.dev' (the DIRECTORY, with dot) not 'chati-dev' (the NPM PACKAGE, with dash)
299
- // Also: avoid double-prefixing if already '.chati.dev' in source
300
- .replace(/(?<!\.)'chati\.dev'/g, "'.chati.dev'")
301
- .replace(/(?<!\.)"chati\.dev"/g, '".chati.dev"');
302
- }
303
-
304
292
  /**
305
293
  * Recursively copy a directory from source to destination, applying:
306
294
  * 1. Path replacement (chati.dev/ → .chati.dev/, artifacts/ at root)
@@ -327,7 +315,7 @@ function copyDirRecursive(srcDir, destDir, provider, relBase = '') {
327
315
  if (isText) {
328
316
  let content = readFileSync(srcPath, 'utf-8');
329
317
  // Always apply path replacement for installed location
330
- content = applyPathReplacement(content);
318
+ content = applyInstalledPathReplacement(content);
331
319
  // Provider adaptation on top (for non-Claude)
332
320
  if (provider !== 'claude' && ADAPTABLE_FILES.has(relPath)) {
333
321
  content = adaptFrameworkFile(content, relPath, provider);
@@ -360,7 +348,7 @@ export function copyFrameworkFiles(destDir, provider = 'claude') {
360
348
  const dest = join(destDir, file);
361
349
  createDir(dirname(dest));
362
350
  let content = readFileSync(src, 'utf-8');
363
- content = applyPathReplacement(content);
351
+ content = applyInstalledPathReplacement(content);
364
352
  if (provider !== 'claude' && ADAPTABLE_FILES.has(file)) {
365
353
  content = adaptFrameworkFile(content, file, provider);
366
354
  }
@@ -429,14 +417,13 @@ Parse the JSON output. This single command already handled: license validation,
429
417
  // (Pre-v4.2.2 bug: this file was never written, hooks never ran.)
430
418
  writeClaudeSettingsWithMerge(join(targetDir, '.claude', 'settings.json'));
431
419
 
432
- // MCP config
433
- if (selectedMCPs.length > 0) {
434
- const mcpConfig = generateClaudeMCPConfig(selectedMCPs);
435
- writeFileSync(
436
- join(targetDir, '.claude', 'mcp.json'),
437
- JSON.stringify(mcpConfig, null, 2) + '\n',
438
- 'utf-8'
439
- );
420
+ // Claude project-scope MCP config lives at the project root. Preserve
421
+ // unknown user-managed servers while replacing CHATI's known entries.
422
+ const mcpPath = join(targetDir, '.mcp.json');
423
+ if (selectedMCPs.length > 0 || existsSync(mcpPath)) {
424
+ const existingMcp = existsSync(mcpPath) ? readFileSync(mcpPath, 'utf-8') : '';
425
+ const mcpConfig = mergeClaudeMCPConfig(existingMcp, selectedMCPs);
426
+ writeFileSync(mcpPath, JSON.stringify(mcpConfig, null, 2) + '\n', 'utf-8');
440
427
  }
441
428
  } else if (ideKey === 'codex-cli') {
442
429
  // Codex CLI: chati skill via .agents/skills/chati/SKILL.md (invoke with $chati)
@@ -448,17 +435,18 @@ Parse the JSON output. This single command already handled: license validation,
448
435
 
449
436
  // Starlark execution policies (Codex sandboxed rule engine)
450
437
  createDir(join(targetDir, '.codex', 'rules'));
438
+ writeCodexConfigWithHooks(join(targetDir, '.codex', 'config.toml'), selectedMCPs);
451
439
  writeFileSync(join(targetDir, '.codex', 'rules', 'constitution-guard.rules'), generateCodexConstitutionGuardRules(), 'utf-8');
452
440
  writeFileSync(join(targetDir, '.codex', 'rules', 'read-protection.rules'), generateCodexReadProtectionRules(), 'utf-8');
453
441
 
454
442
  // .codex/hooks.json — license-guard wired to UserPromptSubmit (per-turn enforcement).
455
- // Codex hooks.json is experimental (requires [features] codex_hooks = true in
456
- // codex config). Without it, license enforcement only happens at slash command
443
+ // Codex hooks require [features] hooks = true in the active project config.
444
+ // Without it, license enforcement only happens at skill entry and the router,
457
445
  // entry — a long-running terminal session would not be re-validated.
458
446
  writeFileSync(
459
447
  join(targetDir, '.codex', 'hooks.json'),
460
448
  JSON.stringify({
461
- $comment: 'chati.dev v4.2.2 experimental Codex hooks. Requires [features] codex_hooks = true.',
449
+ $comment: 'chati.dev Codex lifecycle hooks. Requires [features] hooks = true.',
462
450
  hooks: {
463
451
  UserPromptSubmit: [
464
452
  {
@@ -486,7 +474,7 @@ Parse the JSON output. This single command already handled: license validation,
486
474
  for (const file of contextFileNames) {
487
475
  const src = join(FRAMEWORK_SOURCE, 'context', file);
488
476
  if (existsSync(src)) {
489
- const content = readFileSync(src, 'utf-8');
477
+ const content = applyInstalledPathReplacement(readFileSync(src, 'utf-8'));
490
478
  writeFileSync(join(geminiContextDir, file), adaptFrameworkFile(content, `context/${file}`, 'gemini'), 'utf-8');
491
479
  }
492
480
  }
@@ -506,6 +494,11 @@ Parse the JSON output. This single command already handled: license validation,
506
494
  } else if (ideKey === 'grok-cli') {
507
495
  writeFileSync(join(targetDir, '.grok', 'commands', 'chati.md'), generateGrokRouter({ orchestratorPath }), 'utf-8');
508
496
  writeFileSync(join(targetDir, '.grok', 'session-lock.md'), generateClaudeLocalMd(), 'utf-8');
497
+ const grokConfigPath = join(targetDir, '.grok', 'config.toml');
498
+ if (selectedMCPs.length > 0 || existsSync(grokConfigPath)) {
499
+ const existingConfig = existsSync(grokConfigPath) ? readFileSync(grokConfigPath, 'utf-8') : '';
500
+ writeFileSync(grokConfigPath, upsertManagedMcpToml(existingConfig, selectedMCPs), 'utf-8');
501
+ }
509
502
  } else {
510
503
  // VS Code, Cursor, AntiGravity — generic rules file
511
504
  if (config.rulesFile) {
@@ -515,6 +508,34 @@ Parse the JSON output. This single command already handled: license validation,
515
508
  }
516
509
  }
517
510
 
511
+ export function writeCodexConfigWithHooks(configPath, selectedMCPs = []) {
512
+ createDir(dirname(configPath));
513
+ const original = existsSync(configPath) ? readFileSync(configPath, 'utf-8') : '';
514
+ let hooksEnabled = original;
515
+ if (!original.trim()) {
516
+ hooksEnabled = '[features]\nhooks = true\n';
517
+ } else if (/^\s*features\.hooks\s*=/m.test(original)) {
518
+ hooksEnabled = original.replace(/^\s*features\.hooks\s*=.*$/m, 'features.hooks = true');
519
+ } else if (!/^\s*features\.hooks\s*=\s*true\s*(?:#.*)?$/m.test(original)) {
520
+ const lines = original.split('\n');
521
+ const sectionStart = lines.findIndex((line) => /^\s*\[features\]\s*(?:#.*)?$/.test(line));
522
+ if (sectionStart === -1) {
523
+ const prefix = original.endsWith('\n') ? original : `${original}\n`;
524
+ hooksEnabled = `${prefix}\n[features]\nhooks = true\n`;
525
+ } else {
526
+ let sectionEnd = lines.findIndex((line, index) => index > sectionStart && /^\s*\[.*\]\s*(?:#.*)?$/.test(line));
527
+ if (sectionEnd === -1) sectionEnd = lines.length;
528
+ const hookIndex = lines.findIndex((line, index) =>
529
+ index > sectionStart && index < sectionEnd && /^\s*hooks\s*=/.test(line)
530
+ );
531
+ if (hookIndex !== -1) lines[hookIndex] = 'hooks = true';
532
+ else lines.splice(sectionEnd, 0, 'hooks = true');
533
+ hooksEnabled = lines.join('\n');
534
+ }
535
+ }
536
+ writeFileSync(configPath, upsertManagedMcpToml(hooksEnabled, selectedMCPs), 'utf-8');
537
+ }
538
+
518
539
  /**
519
540
  * Generate provider-agnostic instructions file content.
520
541
  * Used for non-Claude IDEs (.vscode/chati/rules.md, .cursorrules, etc.)
@@ -826,9 +847,14 @@ export function copyCliDependencies(pkgDir, destNodeModules) {
826
847
  let p = require.resolve(depName);
827
848
  while (p && p !== dirname(p)) {
828
849
  p = dirname(p);
829
- if (existsSync(join(p, 'package.json')) && basename(p) === depName.split('/').pop()) {
830
- depDir = p;
831
- break;
850
+ const candidatePackage = join(p, 'package.json');
851
+ if (existsSync(candidatePackage)) {
852
+ try {
853
+ if (JSON.parse(readFileSync(candidatePackage, 'utf-8')).name === depName) {
854
+ depDir = p;
855
+ break;
856
+ }
857
+ } catch { /* keep walking */ }
832
858
  }
833
859
  }
834
860
  } catch { continue; }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Translate repository paths into their installed-project locations.
3
+ *
4
+ * Source files live under `chati.dev/`. Installed framework files live under
5
+ * `.chati.dev/`, while user-facing artifacts remain at project root.
6
+ */
7
+ export function applyInstalledPathReplacement(content) {
8
+ return content
9
+ .replace(/chati\.dev\/artifacts\//g, 'artifacts/')
10
+ .replace(/(?<!\.)chati\.dev\//g, '.chati.dev/')
11
+ .replace(/(?<!\.)'chati\.dev'/g, "'.chati.dev'")
12
+ .replace(/(?<!\.)"chati\.dev"/g, '".chati.dev"');
13
+ }
@@ -13,6 +13,7 @@
13
13
  import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
14
14
  import { join, dirname } from 'path';
15
15
  import { adaptFrameworkFile, ADAPTABLE_FILES } from '../config/framework-adapter.js';
16
+ import { applyInstalledPathReplacement } from './path-replacement.js';
16
17
 
17
18
  /**
18
19
  * Generate adapted framework overlays for all enabled providers.
@@ -41,7 +42,8 @@ export function generateProviderOverlays(targetDir, frameworkSource, allProvider
41
42
 
42
43
  // Read CANONICAL (Claude) content from source, then adapt for target provider
43
44
  const canonicalContent = readFileSync(srcPath, 'utf-8');
44
- const adapted = adaptFrameworkFile(canonicalContent, file, provider);
45
+ const installedContent = applyInstalledPathReplacement(canonicalContent);
46
+ const adapted = adaptFrameworkFile(installedContent, file, provider);
45
47
 
46
48
  const destPath = join(overlayDir, file);
47
49
  mkdirSync(dirname(destPath), { recursive: true });
@@ -35,6 +35,54 @@ function assertString(value, code, label) {
35
35
  if (typeof value !== 'string' || value.trim() === '') throw new ContractError(code, `${label} must be a non-empty string`);
36
36
  }
37
37
 
38
+ function assertExactKeys(value, allowedKeys, label) {
39
+ assertObject(value, 'INVALID_ARTIFACT', label);
40
+ const unexpected = Object.keys(value).filter((key) => !allowedKeys.has(key));
41
+ if (unexpected.length > 0) {
42
+ throw new ContractError('UNEXPECTED_ARTIFACT_FIELD', `${label} contains unsupported fields: ${unexpected.join(', ')}`);
43
+ }
44
+ }
45
+
46
+ const ARTIFACT_KEYS = new Set([
47
+ 'schema_version', 'artifact_type', 'project_id', 'created_at', 'installation',
48
+ 'capability_snapshot', 'brain_read_capability', 'managed_paths',
49
+ 'installation_artifact_id', 'digest',
50
+ ]);
51
+ const INSTALLATION_KEYS = new Set([
52
+ 'installation_id', 'profile', 'enabled_providers', 'capability_snapshot_ref',
53
+ 'policy_ref', 'primary_binding', 'primary_harness', 'external_integrations',
54
+ ]);
55
+ const BINDING_KEYS = new Set([
56
+ 'provider_id', 'harness_id', 'allowed_models', 'allowed_reasoning_configurations',
57
+ ]);
58
+ const SNAPSHOT_KEYS = new Set(['snapshot_id', 'expires_at', 'models']);
59
+ const MODEL_KEYS = new Set([
60
+ 'provider_id', 'model_id', 'actions', 'adjudication_priority',
61
+ 'highest_reasoning_configuration', 'routing_priority', 'tier', 'natural_role',
62
+ ]);
63
+
64
+ function assertClosedArtifactSchema(artifact) {
65
+ assertExactKeys(artifact, ARTIFACT_KEYS, 'artifact');
66
+ assertExactKeys(artifact.installation, INSTALLATION_KEYS, 'installation');
67
+ for (const binding of artifact.installation.enabled_providers ?? []) {
68
+ assertExactKeys(binding, BINDING_KEYS, 'enabled provider binding');
69
+ }
70
+ if (artifact.installation.primary_binding !== undefined) {
71
+ assertExactKeys(artifact.installation.primary_binding, new Set(['provider_id', 'harness_id']), 'primary_binding');
72
+ }
73
+ if (artifact.installation.external_integrations !== undefined) {
74
+ assertExactKeys(artifact.installation.external_integrations, new Set(['clickup']), 'external_integrations');
75
+ }
76
+ assertExactKeys(artifact.capability_snapshot, SNAPSHOT_KEYS, 'capability_snapshot');
77
+ for (const model of artifact.capability_snapshot.models ?? []) {
78
+ assertExactKeys(model, MODEL_KEYS, 'capability model');
79
+ }
80
+ const brainKeys = artifact.brain_read_capability?.status === 'configured'
81
+ ? new Set(['status', 'mode', 'policy_ref'])
82
+ : new Set(['status']);
83
+ assertExactKeys(artifact.brain_read_capability, brainKeys, 'brain_read_capability');
84
+ }
85
+
38
86
  function readClock(clock) {
39
87
  if (typeof clock !== 'function') throw new ContractError('MISSING_CLOCK', 'a deterministic clock function is required');
40
88
  const result = clock();
@@ -127,6 +175,7 @@ export function doctorV2(artifact, { clock = () => new Date() } = {}) {
127
175
  const checks = [];
128
176
  try {
129
177
  assertObject(artifact, 'INVALID_ARTIFACT', 'artifact');
178
+ assertClosedArtifactSchema(artifact);
130
179
  if (artifact.schema_version !== INSTALLATION_SCHEMA_VERSION || artifact.artifact_type !== 'chati-installation') {
131
180
  throw new ContractError('UNSUPPORTED_INSTALLATION_ARTIFACT', 'expected a chati installation artifact v2');
132
181
  }
@@ -138,6 +187,12 @@ export function doctorV2(artifact, { clock = () => new Date() } = {}) {
138
187
  if (!Array.isArray(artifact.managed_paths) || !artifact.managed_paths.every((path) => ensureRelativePath(path))) {
139
188
  throw new ContractError('INVALID_MANAGED_PATH', 'managed_paths must be valid project-relative paths');
140
189
  }
190
+ if (!artifact.managed_paths.includes(INSTALLATION_ARTIFACT_PATH)) {
191
+ throw new ContractError('INVALID_MANAGED_PATH', `managed_paths must include ${INSTALLATION_ARTIFACT_PATH}`);
192
+ }
193
+ if (new Set(artifact.managed_paths).size !== artifact.managed_paths.length) {
194
+ throw new ContractError('DUPLICATE_MANAGED_PATH', 'managed_paths must be unique');
195
+ }
141
196
  checks.push({ name: 'artifact', status: 'pass', message: 'artifact schema and digest are valid' });
142
197
  checks.push({ name: 'selected-bindings', status: 'pass', message: 'only explicitly selected provider/harness bindings are present' });
143
198
  checks.push({ name: 'brain-capability', status: 'pass', message: 'Brain capability is absent or read-only' });