chati-dev 4.2.1 → 4.2.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.
@@ -309,12 +309,146 @@ function checkEntityCount(packageRoot, results) {
309
309
  }
310
310
  }
311
311
 
312
+ /**
313
+ * v4.2.2: Validate that generateClaudeSettings() produces a settings.json
314
+ * that wires all 10 chati hooks via the canonical hookSpecificOutput schema
315
+ * — at the right events and with paths that resolve to existing files.
316
+ *
317
+ * This catches the v4.2.0/v4.2.1 catastrophe (hooks dormant because the
318
+ * installer never wrote .claude/settings.json) at validation time.
319
+ */
320
+ async function checkClaudeHooksWired(packageRoot, results) {
321
+ results.checks++;
322
+ try {
323
+ const genPath = join(packageRoot, 'src', 'config', 'claude-settings-generator.js');
324
+ if (!existsSync(genPath)) {
325
+ results.errors.push('claude-settings-generator.js missing — Claude hooks would not be wired in installs.');
326
+ return;
327
+ }
328
+
329
+ const mod = await import(genPath);
330
+ if (typeof mod.generateClaudeSettings !== 'function') {
331
+ results.errors.push('claude-settings-generator.js does not export generateClaudeSettings.');
332
+ return;
333
+ }
334
+
335
+ const settings = JSON.parse(mod.generateClaudeSettings());
336
+
337
+ // Verify required events
338
+ const requiredEvents = ['UserPromptSubmit', 'PreToolUse', 'PreCompact'];
339
+ const missingEvents = requiredEvents.filter(e => !settings.hooks?.[e]);
340
+ if (missingEvents.length > 0) {
341
+ results.errors.push(`generateClaudeSettings missing events: ${missingEvents.join(', ')}`);
342
+ return;
343
+ }
344
+
345
+ // Collect all referenced hook commands
346
+ const allCommands = [];
347
+ for (const event of Object.values(settings.hooks)) {
348
+ for (const group of event) {
349
+ for (const hook of group.hooks || []) {
350
+ allCommands.push(hook.command || '');
351
+ }
352
+ }
353
+ }
354
+
355
+ // Verify all 10 chati hooks are referenced
356
+ const requiredHooks = [
357
+ 'license-guard', 'prism-engine', 'model-governance',
358
+ 'read-protection', 'constitution-guard', 'mode-governance',
359
+ 'style-guard', 'undercover-guard', 'team-quality-gate', 'session-digest',
360
+ ];
361
+ const missingHooks = requiredHooks.filter(h => !allCommands.some(c => c.includes(h)));
362
+ if (missingHooks.length > 0) {
363
+ results.errors.push(`generateClaudeSettings missing hook references: ${missingHooks.join(', ')}`);
364
+ return;
365
+ }
366
+
367
+ // Verify each referenced hook file exists in framework/hooks/
368
+ const hooksDir = join(packageRoot, 'framework', 'hooks');
369
+ if (existsSync(hooksDir)) {
370
+ const missingFiles = [];
371
+ for (const cmd of allCommands) {
372
+ const match = cmd.match(/hooks\/([^\s]+\.js)/);
373
+ if (match && !existsSync(join(hooksDir, match[1]))) {
374
+ missingFiles.push(match[1]);
375
+ }
376
+ }
377
+ if (missingFiles.length > 0) {
378
+ results.errors.push(`Claude settings reference hook files missing from bundle: ${missingFiles.join(', ')}`);
379
+ return;
380
+ }
381
+ }
382
+
383
+ results.passed++;
384
+ } catch (err) {
385
+ results.errors.push(`checkClaudeHooksWired failed: ${err.message}`);
386
+ }
387
+ }
388
+
389
+ /**
390
+ * v4.2.2: Verify license-guard is wired for ALL three providers.
391
+ * Without this, an expired trial keeps working forever — exactly the bug
392
+ * the user flagged in v4.2.1 (they could see chati.dev with no license check).
393
+ */
394
+ async function checkLicenseEntryPointAcrossProviders(packageRoot, results) {
395
+ results.checks++;
396
+ try {
397
+ const missing = [];
398
+
399
+ // Claude — settings.json must include license-guard in UserPromptSubmit
400
+ const genPath = join(packageRoot, 'src', 'config', 'claude-settings-generator.js');
401
+ if (existsSync(genPath)) {
402
+ const mod = await import(genPath);
403
+ const settings = JSON.parse(mod.generateClaudeSettings());
404
+ const ups = settings.hooks?.UserPromptSubmit || [];
405
+ const claudeCmds = ups.flatMap(g => (g.hooks || []).map(h => h.command || ''));
406
+ if (!claudeCmds.some(c => c.includes('license-guard'))) {
407
+ missing.push('Claude (UserPromptSubmit)');
408
+ }
409
+ } else {
410
+ missing.push('Claude (generator file missing)');
411
+ }
412
+
413
+ // Gemini — HOOK_MAP must include license-guard at BeforeModel
414
+ const geminiPath = join(packageRoot, 'src', 'config', 'gemini-hooks-generator.js');
415
+ if (existsSync(geminiPath)) {
416
+ const mod = await import(geminiPath);
417
+ if (!mod.HOOK_MAP?.['license-guard'] || mod.HOOK_MAP['license-guard'].event !== 'BeforeModel') {
418
+ missing.push('Gemini (HOOK_MAP missing license-guard at BeforeModel)');
419
+ }
420
+ } else {
421
+ missing.push('Gemini (generator file missing)');
422
+ }
423
+
424
+ // Codex — installer/core.js must write .codex/hooks.json with license-guard
425
+ const corePath = join(packageRoot, 'src', 'installer', 'core.js');
426
+ if (existsSync(corePath)) {
427
+ const coreContent = readFileSync(corePath, 'utf-8');
428
+ if (!coreContent.includes('.codex/hooks.json') || !coreContent.includes("license-guard")) {
429
+ missing.push('Codex (.codex/hooks.json not wired with license-guard in core.js)');
430
+ }
431
+ } else {
432
+ missing.push('Codex (core.js missing)');
433
+ }
434
+
435
+ if (missing.length > 0) {
436
+ results.errors.push(`License enforcement gaps: ${missing.join('; ')}`);
437
+ return;
438
+ }
439
+
440
+ results.passed++;
441
+ } catch (err) {
442
+ results.errors.push(`checkLicenseEntryPointAcrossProviders failed: ${err.message}`);
443
+ }
444
+ }
445
+
312
446
  /**
313
447
  * Validate the npm package completeness.
314
448
  * @param {string} packageRoot - Root of the package (packages/chati-dev/)
315
- * @returns {{ errors: string[], warnings: string[], checks: number, passed: number }}
449
+ * @returns {Promise<{ errors: string[], warnings: string[], checks: number, passed: number }>}
316
450
  */
317
- export function validatePackage(packageRoot) {
451
+ export async function validatePackage(packageRoot) {
318
452
  const results = { errors: [], warnings: [], checks: 0, passed: 0 };
319
453
 
320
454
  checkFrameworkExists(packageRoot, results);
@@ -326,6 +460,8 @@ export function validatePackage(packageRoot) {
326
460
  checkEntityCount(packageRoot, results);
327
461
  checkHooksParity(packageRoot, results);
328
462
  checkSourceBundleParity(packageRoot, results);
463
+ await checkClaudeHooksWired(packageRoot, results);
464
+ await checkLicenseEntryPointAcrossProviders(packageRoot, results);
329
465
 
330
466
  return results;
331
467
  }
@@ -357,11 +493,13 @@ function formatResults(results) {
357
493
  // CLI entry point
358
494
  if (process.argv[1] === fileURLToPath(import.meta.url)) {
359
495
  const packageRoot = join(__dirname, '..');
360
- const results = validatePackage(packageRoot);
361
-
362
- console.log(formatResults(results));
363
-
364
- if (results.errors.length > 0) {
496
+ validatePackage(packageRoot).then(results => {
497
+ console.log(formatResults(results));
498
+ if (results.errors.length > 0) {
499
+ process.exit(1);
500
+ }
501
+ }).catch(err => {
502
+ console.error('validate-package failed:', err);
365
503
  process.exit(1);
366
- }
504
+ });
367
505
  }
@@ -0,0 +1,206 @@
1
+ /**
2
+ * Claude Code settings.json generator.
3
+ *
4
+ * Produces .claude/settings.json content that wires the 10 chati.dev hooks
5
+ * into Claude Code's hook system and applies a baseline permission policy.
6
+ *
7
+ * Background: prior to v4.2.2, the installer wrote hooks to
8
+ * chati.dev/hooks/settings.json — but Claude Code reads .claude/settings.json,
9
+ * not the framework's bundled file. As a result, every hook was dormant for
10
+ * every Claude Code user since the project started. This generator is the fix:
11
+ * it produces the file Claude Code actually loads, registers all 10 hooks at
12
+ * the canonical events, and applies a generic permission policy modeled on
13
+ * production Claude Code setups.
14
+ *
15
+ * Hook output schema (v4.2.2): all PreToolUse hooks now use the canonical
16
+ * `hookSpecificOutput.permissionDecision` field per Anthropic docs at
17
+ * https://code.claude.com/docs/en/hooks.md. The previous `decision` field
18
+ * was silently no-op'd by Claude Code.
19
+ */
20
+
21
+ /**
22
+ * Hook registration map. Each entry is one hook script and the events it
23
+ * should fire on. Source of truth lives in chati.dev/hooks/.
24
+ */
25
+ const HOOK_REGISTRY = [
26
+ // UserPromptSubmit — fires on every user message turn.
27
+ // license-guard MUST be first so license check runs before any other work.
28
+ { name: 'license-guard', event: 'UserPromptSubmit', matcher: '.*' },
29
+ { name: 'prism-engine', event: 'UserPromptSubmit', matcher: '.*' },
30
+ { name: 'model-governance', event: 'UserPromptSubmit', matcher: '.*' },
31
+
32
+ // PreToolUse — fires before each tool call. Matcher targets specific tools.
33
+ { name: 'read-protection', event: 'PreToolUse', matcher: 'Read' },
34
+ { name: 'constitution-guard',event: 'PreToolUse', matcher: 'Bash|Write|Edit' },
35
+ { name: 'mode-governance', event: 'PreToolUse', matcher: 'Write|Edit' },
36
+ { name: 'style-guard', event: 'PreToolUse', matcher: 'Write|Edit|Bash' },
37
+ { name: 'undercover-guard', event: 'PreToolUse', matcher: 'Write|Edit|Bash' },
38
+ { name: 'team-quality-gate', event: 'PreToolUse', matcher: 'Write|Edit' },
39
+
40
+ // PreCompact — fires before context compaction (observational).
41
+ { name: 'session-digest', event: 'PreCompact', matcher: '' },
42
+ ];
43
+
44
+ /**
45
+ * Baseline permission allow list.
46
+ * Modeled on production Claude Code setups: lets the assistant work freely
47
+ * inside the project without prompting for every tool call. Personal MCP
48
+ * permissions are intentionally NOT included — those are user-specific.
49
+ */
50
+ const PERMISSIONS_ALLOW = [
51
+ 'Read(**/*)',
52
+ 'Write(**/*)',
53
+ 'Edit(**/*)',
54
+ 'Bash',
55
+ 'WebFetch',
56
+ 'WebSearch',
57
+ 'Task',
58
+ 'Glob',
59
+ 'Grep',
60
+ 'NotebookEdit',
61
+ 'Skill(*)',
62
+ 'TodoWrite',
63
+ 'AskUserQuestion',
64
+ 'EnterPlanMode',
65
+ 'ExitPlanMode',
66
+ 'KillShell',
67
+ ];
68
+
69
+ /**
70
+ * Baseline permission deny list.
71
+ *
72
+ * Categories:
73
+ * - Catastrophic filesystem: deletes that hit root, mass-format, recursive chmod
74
+ * - Git destructive: force push, hard reset, .git removal, hook bypass
75
+ * - System path writes: /etc, /usr, /System, /bin, /sbin (note `//` prefix
76
+ * means absolute filesystem path, not project-relative)
77
+ * - Secret reads: SSH keys, AWS/GCP credentials, npm tokens, GitHub tokens
78
+ * - Accidental publish: npm/yarn/pnpm/pip/cargo publish commands
79
+ *
80
+ * Path syntax (from Claude Code docs):
81
+ * - `Bash(cmd:*)` matches "cmd" with any args
82
+ * - `~/path/**` expands ~ to home dir, ** matches recursive
83
+ * - `//etc/**` double-slash means absolute filesystem path
84
+ * - `/path/**` single-slash is project-root-relative (NOT absolute)
85
+ */
86
+ const PERMISSIONS_DENY = [
87
+ // Catastrophic filesystem
88
+ 'Bash(rm -rf /)',
89
+ 'Bash(rm -rf /*)',
90
+ 'Bash(rm -rf ~)',
91
+ 'Bash(rm -rf ~/*)',
92
+ 'Bash(rm -rf $HOME)',
93
+ 'Bash(rm -rf $HOME/*)',
94
+ 'Bash(sudo rm -rf:*)',
95
+ 'Bash(mkfs:*)',
96
+ 'Bash(dd if=/dev/zero:*)',
97
+ 'Bash(dd if=/dev/random:*)',
98
+ 'Bash(chmod -R 777 /)',
99
+ 'Bash(chown -R:* /)',
100
+
101
+ // Git destructive
102
+ 'Bash(git push --force:*)',
103
+ 'Bash(git push -f:*)',
104
+ 'Bash(git push --force-with-lease:*)',
105
+ 'Bash(git reset --hard:*)',
106
+ 'Bash(git clean -fd:*)',
107
+ 'Bash(git clean -fdx:*)',
108
+ 'Bash(git clean -fx:*)',
109
+ 'Bash(rm -rf .git)',
110
+ 'Bash(rm -rf .git/*)',
111
+
112
+ // Bypass hooks (always suspect)
113
+ 'Bash(git commit --no-verify:*)',
114
+ 'Bash(git commit -n:*)',
115
+ 'Bash(git commit --no-gpg-sign:*)',
116
+ 'Bash(git rebase --no-verify:*)',
117
+
118
+ // System path writes — `//` prefix = absolute filesystem
119
+ 'Write(//etc/**)',
120
+ 'Write(//usr/**)',
121
+ 'Write(//System/**)',
122
+ 'Write(//bin/**)',
123
+ 'Write(//sbin/**)',
124
+ 'Write(//Library/**)',
125
+ 'Edit(//etc/**)',
126
+ 'Edit(//usr/**)',
127
+ 'Edit(//System/**)',
128
+ 'Edit(//bin/**)',
129
+ 'Edit(//sbin/**)',
130
+ 'Edit(//Library/**)',
131
+
132
+ // Credentials and secrets
133
+ 'Read(~/.ssh/**)',
134
+ 'Read(~/.aws/credentials)',
135
+ 'Read(~/.aws/config)',
136
+ 'Read(~/.gnupg/**)',
137
+ 'Read(~/.config/gh/hosts.yml)',
138
+ 'Read(~/.docker/config.json)',
139
+ 'Read(~/.npmrc)',
140
+ 'Read(~/.pypirc)',
141
+ 'Read(~/.netrc)',
142
+
143
+ // Accidental publish
144
+ 'Bash(npm publish:*)',
145
+ 'Bash(yarn publish:*)',
146
+ 'Bash(pnpm publish:*)',
147
+ 'Bash(pip upload:*)',
148
+ 'Bash(twine upload:*)',
149
+ 'Bash(cargo publish:*)',
150
+ ];
151
+
152
+ /**
153
+ * Group HOOK_REGISTRY entries by event, then by matcher within each event.
154
+ * Returns the shape Claude Code expects under the top-level "hooks" key.
155
+ */
156
+ function buildHooksObject() {
157
+ const events = {};
158
+
159
+ for (const entry of HOOK_REGISTRY) {
160
+ if (!events[entry.event]) events[entry.event] = new Map();
161
+ const matcherGroups = events[entry.event];
162
+ if (!matcherGroups.has(entry.matcher)) {
163
+ matcherGroups.set(entry.matcher, []);
164
+ }
165
+ matcherGroups.get(entry.matcher).push({
166
+ type: 'command',
167
+ command: `node chati.dev/hooks/${entry.name}.js`,
168
+ });
169
+ }
170
+
171
+ const result = {};
172
+ for (const [event, matcherGroups] of Object.entries(events)) {
173
+ result[event] = [];
174
+ for (const [matcher, hooks] of matcherGroups.entries()) {
175
+ const group = { hooks };
176
+ if (matcher) group.matcher = matcher;
177
+ result[event].push(group);
178
+ }
179
+ }
180
+ return result;
181
+ }
182
+
183
+ /**
184
+ * Generate the full settings.json content.
185
+ *
186
+ * @returns {string} JSON string with trailing newline, ready to write to disk.
187
+ */
188
+ export function generateClaudeSettings() {
189
+ const settings = {
190
+ $schema: 'https://json.schemastore.org/claude-code-settings.json',
191
+ permissions: {
192
+ allow: PERMISSIONS_ALLOW,
193
+ deny: PERMISSIONS_DENY,
194
+ defaultMode: 'default',
195
+ },
196
+ hooks: buildHooksObject(),
197
+ };
198
+
199
+ return JSON.stringify(settings, null, 2) + '\n';
200
+ }
201
+
202
+ export {
203
+ HOOK_REGISTRY,
204
+ PERMISSIONS_ALLOW,
205
+ PERMISSIONS_DENY,
206
+ };
@@ -22,6 +22,10 @@
22
22
  * Maps each chati.dev hook to its Gemini CLI equivalent event.
23
23
  */
24
24
  export const HOOK_MAP = {
25
+ // license-guard MUST be first on BeforeModel so license check runs before
26
+ // any model call. Without this, an expired trial could keep using Gemini
27
+ // indefinitely from a long-running session.
28
+ 'license-guard': { event: 'BeforeModel', description: 'Validate license on every turn (chati.dev governance)' },
25
29
  'prism-engine': { event: 'BeforeModel', description: 'Inject PRISM context into model prompt' },
26
30
  'model-governance': { event: 'BeforeModel', description: 'Advisory: recommended model per agent' },
27
31
  'mode-governance': { event: 'BeforeTool', description: 'Block writes outside current mode scope' },
@@ -611,6 +615,7 @@ main();
611
615
  */
612
616
  export function generateAllGeminiHooks() {
613
617
  return {
618
+ 'license-guard.js': generateLicenseGuard(),
614
619
  'prism-engine.js': generatePrismEngine(),
615
620
  'model-governance.js': generateModelGovernance(),
616
621
  'mode-governance.js': generateModeGovernance(),
@@ -622,6 +627,59 @@ export function generateAllGeminiHooks() {
622
627
  };
623
628
  }
624
629
 
630
+ /**
631
+ * Generate the license guard hook for Gemini CLI.
632
+ * BeforeModel event — validates license on every turn.
633
+ *
634
+ * Delegates to chati.dev/hooks/license-guard.js (canonical implementation)
635
+ * via dynamic import of checkLicense(). Translates the verdict object to
636
+ * Gemini's blocking schema (exit code 2 + stderr is the universal block).
637
+ */
638
+ function generateLicenseGuard() {
639
+ return `${HOOK_HEADER}
640
+ async function main() {
641
+ let input = '';
642
+ for await (const chunk of process.stdin) {
643
+ input += chunk;
644
+ }
645
+
646
+ try {
647
+ const event = JSON.parse(input);
648
+ const cwd = event.cwd || process.cwd();
649
+
650
+ // Delegate to canonical license check
651
+ const hookPath = join(cwd, 'chati.dev', 'hooks', 'license-guard.js');
652
+ if (!existsSync(hookPath)) {
653
+ // No canonical hook present — fail open
654
+ console.log(JSON.stringify({}));
655
+ return;
656
+ }
657
+
658
+ const mod = await import(hookPath);
659
+ if (typeof mod.checkLicense !== 'function') {
660
+ console.log(JSON.stringify({}));
661
+ return;
662
+ }
663
+
664
+ const result = await mod.checkLicense();
665
+ if (result.valid) {
666
+ console.log(JSON.stringify({}));
667
+ return;
668
+ }
669
+
670
+ // Block via exit code 2 (universal Gemini block) + stderr message.
671
+ process.stderr.write(result.reason || 'License invalid');
672
+ process.exit(2);
673
+ } catch {
674
+ // Fail open on any parse/import error — never block on bugs.
675
+ console.log(JSON.stringify({}));
676
+ }
677
+ }
678
+
679
+ main();
680
+ `;
681
+ }
682
+
625
683
  /**
626
684
  * Generate .gemini/settings.json content with hook configuration.
627
685
  *
@@ -6,6 +6,7 @@ import { generateClaudeMCPConfig } from '../config/mcp-configs.js';
6
6
  import { generateSessionYaml, generateConfigYaml, generateClaudeMd, generateClaudeLocalMd, generateCodexSkill, generateGeminiRouter, generateGeminiSessionLock, generateAgentsOverrideMd, generateCodexConstitutionGuardRules, generateCodexReadProtectionRules } from './templates.js';
7
7
  import { generateContextFiles } from '../config/context-file-generator.js';
8
8
  import { adaptFrameworkFile, ADAPTABLE_FILES } from '../config/framework-adapter.js';
9
+ import { generateClaudeSettings } from '../config/claude-settings-generator.js';
9
10
  import { generateProviderOverlays } from './provider-overlay.js';
10
11
  import { verifyManifest } from './manifest.js';
11
12
 
@@ -290,6 +291,12 @@ Pass through all context: session state, handoffs, artifacts, and user input.
290
291
  `;
291
292
  writeFileSync(join(targetDir, '.claude', 'commands', 'chati.md'), routerContent, 'utf-8');
292
293
 
294
+ // .claude/settings.json — wires the 10 chati.dev hooks into Claude Code's
295
+ // hook system and applies a baseline permission policy. THIS IS THE FILE
296
+ // CLAUDE CODE READS — without it, every hook in chati.dev/hooks/ is dormant.
297
+ // (Pre-v4.2.2 bug: this file was never written, hooks never ran.)
298
+ writeClaudeSettingsWithMerge(join(targetDir, '.claude', 'settings.json'));
299
+
293
300
  // MCP config
294
301
  if (selectedMCPs.length > 0) {
295
302
  const mcpConfig = generateClaudeMCPConfig(selectedMCPs);
@@ -307,10 +314,35 @@ Pass through all context: session state, handoffs, artifacts, and user input.
307
314
  // Session lock override file (equivalent to CLAUDE.local.md)
308
315
  writeFileSync(join(targetDir, 'AGENTS.override.md'), generateAgentsOverrideMd(), 'utf-8');
309
316
 
310
- // Starlark execution policies (equivalent to Claude hooks for constitution guard + read protection)
317
+ // Starlark execution policies (Codex sandboxed rule engine)
311
318
  createDir(join(targetDir, '.codex', 'rules'));
312
319
  writeFileSync(join(targetDir, '.codex', 'rules', 'constitution-guard.rules'), generateCodexConstitutionGuardRules(), 'utf-8');
313
320
  writeFileSync(join(targetDir, '.codex', 'rules', 'read-protection.rules'), generateCodexReadProtectionRules(), 'utf-8');
321
+
322
+ // .codex/hooks.json — license-guard wired to UserPromptSubmit (per-turn enforcement).
323
+ // Codex hooks.json is experimental (requires [features] codex_hooks = true in
324
+ // codex config). Without it, license enforcement only happens at slash command
325
+ // entry — a long-running terminal session would not be re-validated.
326
+ writeFileSync(
327
+ join(targetDir, '.codex', 'hooks.json'),
328
+ JSON.stringify({
329
+ $comment: 'chati.dev v4.2.2 — experimental Codex hooks. Requires [features] codex_hooks = true.',
330
+ hooks: {
331
+ UserPromptSubmit: [
332
+ {
333
+ matcher: '.*',
334
+ hooks: [
335
+ {
336
+ type: 'command',
337
+ command: 'node chati.dev/hooks/license-guard.js',
338
+ },
339
+ ],
340
+ },
341
+ ],
342
+ },
343
+ }, null, 2) + '\n',
344
+ 'utf-8'
345
+ );
314
346
  } else if (ideKey === 'gemini-cli') {
315
347
  // Gemini CLI: TOML command file (native format for /chati command)
316
348
  writeFileSync(join(targetDir, '.gemini', 'commands', 'chati.toml'), generateGeminiRouter({ orchestratorPath }), 'utf-8');
@@ -430,3 +462,106 @@ function createDir(dir) {
430
462
  mkdirSync(dir, { recursive: true });
431
463
  }
432
464
  }
465
+
466
+ /**
467
+ * Write .claude/settings.json with safe merge against existing user content.
468
+ *
469
+ * If the file does not exist: write fresh chati settings.
470
+ * If the file exists and is valid JSON: merge — chati hooks/permissions are
471
+ * UNIONED into existing values rather than replacing them. User customizations
472
+ * (env, statusLine, plugins, language, alwaysThinkingEnabled, etc.) are preserved.
473
+ * If the file exists but is malformed: leave it alone, log a warning. We will
474
+ * not silently destroy user data.
475
+ */
476
+ function writeClaudeSettingsWithMerge(settingsPath) {
477
+ createDir(dirname(settingsPath));
478
+ const fresh = JSON.parse(generateClaudeSettings());
479
+
480
+ if (!existsSync(settingsPath)) {
481
+ writeFileSync(settingsPath, JSON.stringify(fresh, null, 2) + '\n', 'utf-8');
482
+ return;
483
+ }
484
+
485
+ let existing;
486
+ try {
487
+ existing = JSON.parse(readFileSync(settingsPath, 'utf-8'));
488
+ } catch (err) {
489
+ process.stderr.write(`[chati] WARNING: .claude/settings.json exists but is not valid JSON (${err.message}). Skipping merge — chati hooks will NOT be wired. Fix the file and re-run install.\n`);
490
+ return;
491
+ }
492
+
493
+ const merged = mergeClaudeSettings(existing, fresh);
494
+ writeFileSync(settingsPath, JSON.stringify(merged, null, 2) + '\n', 'utf-8');
495
+ }
496
+
497
+ /**
498
+ * Merge two Claude Code settings.json objects.
499
+ *
500
+ * - permissions.allow / permissions.deny: array union (de-duplicated)
501
+ * - permissions.defaultMode: keep existing if set, else use fresh
502
+ * - hooks: per-event union — chati hook commands are appended to existing
503
+ * matcher groups; user's hooks for the same events are preserved
504
+ * - $schema: prefer fresh (always our canonical URL)
505
+ * - all other top-level keys (env, statusLine, plugins, language, etc.):
506
+ * keep existing untouched
507
+ */
508
+ export function mergeClaudeSettings(existing, fresh) {
509
+ const merged = { ...existing };
510
+
511
+ // $schema — chati's canonical URL wins
512
+ if (fresh.$schema) merged.$schema = fresh.$schema;
513
+
514
+ // permissions
515
+ const existingPerms = existing.permissions || {};
516
+ const freshPerms = fresh.permissions || {};
517
+ merged.permissions = {
518
+ ...existingPerms,
519
+ allow: unionArrays(existingPerms.allow, freshPerms.allow),
520
+ deny: unionArrays(existingPerms.deny, freshPerms.deny),
521
+ defaultMode: existingPerms.defaultMode || freshPerms.defaultMode || 'default',
522
+ };
523
+
524
+ // hooks — per-event merge
525
+ const existingHooks = existing.hooks || {};
526
+ const freshHooks = fresh.hooks || {};
527
+ merged.hooks = { ...existingHooks };
528
+
529
+ for (const [eventName, freshGroups] of Object.entries(freshHooks)) {
530
+ const existingGroups = existingHooks[eventName] || [];
531
+ // Append fresh groups; we don't dedup at the group level (different
532
+ // matchers are different groups). Within a single fresh group, the
533
+ // hook commands are unique to chati so duplication risk is minimal.
534
+ // To be safe, dedup by command string across the merged event.
535
+ const seen = new Set();
536
+ const collected = [];
537
+ for (const group of [...existingGroups, ...freshGroups]) {
538
+ const dedupedHooks = (group.hooks || []).filter(h => {
539
+ const key = `${group.matcher || ''}|${h.command}`;
540
+ if (seen.has(key)) return false;
541
+ seen.add(key);
542
+ return true;
543
+ });
544
+ if (dedupedHooks.length > 0) {
545
+ collected.push({
546
+ ...(group.matcher !== undefined ? { matcher: group.matcher } : {}),
547
+ hooks: dedupedHooks,
548
+ });
549
+ }
550
+ }
551
+ merged.hooks[eventName] = collected;
552
+ }
553
+
554
+ return merged;
555
+ }
556
+
557
+ function unionArrays(a, b) {
558
+ const out = [];
559
+ const seen = new Set();
560
+ for (const item of [...(a || []), ...(b || [])]) {
561
+ if (!seen.has(item)) {
562
+ seen.add(item);
563
+ out.push(item);
564
+ }
565
+ }
566
+ return out;
567
+ }
@@ -134,8 +134,12 @@ export function generateConfigYaml(config) {
134
134
  model_fallback: true,
135
135
  frustration_detection: true,
136
136
  bash_security_checks: true,
137
- // Agent Teams (v4.2.0 — Article XXI)
138
- agent_teams: false,
137
+ // Agent Teams (Article XXI) — default ON in v4.2.2.
138
+ // Only effective when provider === 'claude' (gated in cli.js
139
+ // isAgentTeamsEnabled). Gemini and Codex always fall back to sequential
140
+ // pipeline silently. See plan: was previously false for "safe rollout"
141
+ // but the v4.2.0 launch never actually shipped, so this is a fresh start.
142
+ agent_teams: true,
139
143
  team_planning_size: 3,
140
144
  team_build_size: 2,
141
145
  team_echo_threshold: 0.92,