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.
- package/framework/agents/build/dev.md +1 -1
- package/framework/agents/deploy/devops.md +1 -1
- package/framework/agents/discover/brief.md +1 -1
- package/framework/agents/discover/brownfield-wu.md +1 -1
- package/framework/agents/discover/greenfield-wu.md +1 -1
- package/framework/agents/plan/architect-data-engineer.md +1 -1
- package/framework/agents/plan/architect-system.md +1 -1
- package/framework/agents/plan/architect.md +1 -1
- package/framework/agents/plan/detail.md +1 -1
- package/framework/agents/plan/phases.md +1 -1
- package/framework/agents/plan/tasks.md +1 -1
- package/framework/agents/plan/ux-brand-architect.md +1 -1
- package/framework/agents/plan/ux-component-engineer.md +1 -1
- package/framework/agents/plan/ux-researcher.md +1 -1
- package/framework/agents/plan/ux.md +1 -1
- package/framework/agents/quality/qa-implementation.md +1 -1
- package/framework/agents/quality/qa-planning.md +1 -1
- package/framework/agents/quality/qa-visual.md +1 -1
- package/framework/agents/shared/visualizer.md +1 -1
- package/framework/config.yaml +2 -2
- package/framework/context/root.md +1 -1
- package/framework/data/entity-registry.yaml +1 -1
- package/framework/hooks/license-guard.js +19 -0
- package/framework/manifest.json +53 -48
- package/framework/manifest.sig +1 -1
- package/framework/orchestrator/chati-router.js +177 -71
- package/framework/orchestrator/chati.md +1 -1
- package/framework/package.json +3 -0
- package/package.json +1 -1
- package/src/config/claude-settings-generator.js +6 -6
- package/src/config/context-file-generator.js +2 -2
- package/src/config/ide-configs.js +3 -3
- package/src/config/mcp-configs.js +80 -10
- package/src/installer/core.js +60 -34
- package/src/installer/path-replacement.js +13 -0
- package/src/installer/provider-overlay.js +3 -1
- package/src/installer-v2/index.js +55 -0
package/src/installer/core.js
CHANGED
|
@@ -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
|
|
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 {
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
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
|
|
456
|
-
//
|
|
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
|
|
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
|
-
|
|
830
|
-
|
|
831
|
-
|
|
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
|
|
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' });
|