chati-dev 3.3.2 → 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -4
- package/framework/config.yaml +18 -3
- package/framework/constitution.md +12 -10
- package/framework/context/governance.md +2 -0
- package/framework/context/root.md +1 -1
- package/framework/intelligence/context-engine.md +22 -17
- package/package.json +1 -1
- package/scripts/doctor/checks/agents.js +77 -0
- package/scripts/doctor/checks/constitution.js +41 -0
- package/scripts/doctor/checks/domain-alignment.js +58 -0
- package/scripts/doctor/checks/prism-layers.js +84 -0
- package/scripts/doctor/checks/registry.js +55 -0
- package/scripts/doctor/checks/schemas.js +61 -0
- package/scripts/doctor/fixes/reference-fix.js +100 -0
- package/scripts/doctor/fixes/registry-fix.js +56 -0
- package/scripts/doctor/index.js +212 -0
- package/scripts/health-check.js +8 -8
- package/src/autonomy/surface-criteria.js +226 -0
- package/src/context/bracket-tracker.js +44 -13
- package/src/context/domain-loader.js +22 -0
- package/src/context/engine.js +18 -7
- package/src/context/formatter.js +21 -1
- package/src/context/layers/l5-keywords.js +53 -0
- package/src/intelligence/context-status.js +20 -9
- package/src/intelligence/decision-engine.js +253 -0
- package/src/terminal/prompt-builder.js +341 -1
- package/src/terminal/run-agent.js +15 -0
- package/src/terminal/run-parallel.js +77 -5
- package/src/terminal/spawner.js +13 -0
- package/src/utils/feature-flags.js +106 -0
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* output format instructions.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import { existsSync, readFileSync } from 'fs';
|
|
11
|
+
import { existsSync, readFileSync, readdirSync } from 'fs';
|
|
12
12
|
import { join } from 'path';
|
|
13
13
|
import { runPrism } from '../context/engine.js';
|
|
14
14
|
import { loadHandoff, formatHandoff } from '../tasks/handoff.js';
|
|
@@ -16,6 +16,7 @@ import { getWriteScope } from './isolation.js';
|
|
|
16
16
|
import { resolveOverlayPath } from '../installer/provider-overlay.js';
|
|
17
17
|
import { resolveProviderForAgent } from './cli-registry.js';
|
|
18
18
|
import { buildCompactGotchasSummary } from '../memory/gotchas-injector.js';
|
|
19
|
+
import { estimateTokens } from './cost-tracker.js';
|
|
19
20
|
|
|
20
21
|
// Import AGENT_MODELS from model-governance (safe — named export,
|
|
21
22
|
// does not trigger main() which is guarded by fileURLToPath check).
|
|
@@ -39,6 +40,69 @@ export const AGENT_FILE_MAP = {
|
|
|
39
40
|
devops: 'chati.dev/agents/deploy/devops.md',
|
|
40
41
|
};
|
|
41
42
|
|
|
43
|
+
/**
|
|
44
|
+
* 3-Tier Tool Mesh — Token-aware tool loading profiles per agent.
|
|
45
|
+
*
|
|
46
|
+
* T1: Core tools (always loaded) — Read, Write, Edit, Bash, Grep, Glob
|
|
47
|
+
* T2: On-demand tools (loaded when relevant) — WebSearch, WebFetch, Task
|
|
48
|
+
* T3: External MCP tools (specialized) — playwright, docker, etc.
|
|
49
|
+
*
|
|
50
|
+
* Each agent gets a customized profile based on their role to save
|
|
51
|
+
* context window budget by not loading unnecessary tool instructions.
|
|
52
|
+
*/
|
|
53
|
+
export const TOOL_PROFILES = {
|
|
54
|
+
'greenfield-wu': { T1: ['Read', 'Glob', 'Grep', 'Bash'], T2: ['WebSearch'], T3: [] },
|
|
55
|
+
'brownfield-wu': { T1: ['Read', 'Glob', 'Grep', 'Bash'], T2: ['WebSearch'], T3: [] },
|
|
56
|
+
brief: { T1: ['Read', 'Glob', 'Grep'], T2: [], T3: [] },
|
|
57
|
+
detail: { T1: ['Read', 'Write', 'Edit', 'Glob', 'Grep'], T2: [], T3: [] },
|
|
58
|
+
architect: { T1: ['Read', 'Write', 'Edit', 'Glob', 'Grep'], T2: ['WebSearch', 'WebFetch'], T3: [] },
|
|
59
|
+
ux: { T1: ['Read', 'Write', 'Edit', 'Glob'], T2: ['WebSearch', 'WebFetch'], T3: [] },
|
|
60
|
+
phases: { T1: ['Read', 'Write', 'Edit', 'Glob', 'Grep'], T2: [], T3: [] },
|
|
61
|
+
tasks: { T1: ['Read', 'Write', 'Edit', 'Glob', 'Grep'], T2: [], T3: [] },
|
|
62
|
+
'qa-planning': { T1: ['Read', 'Glob', 'Grep'], T2: [], T3: [] },
|
|
63
|
+
dev: { T1: ['Read', 'Write', 'Edit', 'Bash', 'Glob', 'Grep'], T2: ['WebSearch', 'WebFetch', 'Task'], T3: ['playwright'] },
|
|
64
|
+
'qa-implementation': { T1: ['Read', 'Bash', 'Glob', 'Grep'], T2: ['Task'], T3: [] },
|
|
65
|
+
devops: { T1: ['Read', 'Write', 'Edit', 'Bash', 'Glob', 'Grep'], T2: ['WebSearch'], T3: ['docker'] },
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Get the tool profile for an agent.
|
|
70
|
+
*
|
|
71
|
+
* @param {string} agent - Agent name
|
|
72
|
+
* @returns {{ T1: string[], T2: string[], T3: string[] }}
|
|
73
|
+
*/
|
|
74
|
+
export function getToolProfile(agent) {
|
|
75
|
+
return TOOL_PROFILES[agent] || { T1: ['Read', 'Write', 'Edit', 'Bash', 'Glob', 'Grep'], T2: [], T3: [] };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Build tool mesh section for prompt injection.
|
|
80
|
+
*
|
|
81
|
+
* @param {string} agent - Agent name
|
|
82
|
+
* @returns {string} Formatted tool mesh instructions
|
|
83
|
+
*/
|
|
84
|
+
export function buildToolMeshSection(agent) {
|
|
85
|
+
const profile = getToolProfile(agent);
|
|
86
|
+
const lines = [
|
|
87
|
+
'<!-- TOOL MESH -->',
|
|
88
|
+
'## Available Tools',
|
|
89
|
+
'',
|
|
90
|
+
`**Core (T1)**: ${profile.T1.join(', ')}`,
|
|
91
|
+
];
|
|
92
|
+
|
|
93
|
+
if (profile.T2.length > 0) {
|
|
94
|
+
lines.push(`**On-demand (T2)**: ${profile.T2.join(', ')} — use only when needed`);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (profile.T3.length > 0) {
|
|
98
|
+
lines.push(`**External (T3)**: ${profile.T3.join(', ')} — available via MCP if configured`);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
lines.push('', 'Prefer T1 tools for all standard operations. Use T2/T3 only when the task requires them.');
|
|
102
|
+
|
|
103
|
+
return lines.join('\n');
|
|
104
|
+
}
|
|
105
|
+
|
|
42
106
|
/**
|
|
43
107
|
* @typedef {object} PromptBuildConfig
|
|
44
108
|
* @property {string} agent - Agent name (e.g. 'detail')
|
|
@@ -127,6 +191,9 @@ export function buildAgentPrompt(config) {
|
|
|
127
191
|
|
|
128
192
|
const prompt = sections.join('\n\n---\n\n');
|
|
129
193
|
|
|
194
|
+
// 10. Prompt size guard — validate before returning
|
|
195
|
+
const sizeCheck = validatePromptSize(prompt, resolvedProvider);
|
|
196
|
+
|
|
130
197
|
return {
|
|
131
198
|
prompt,
|
|
132
199
|
model,
|
|
@@ -135,6 +202,7 @@ export function buildAgentPrompt(config) {
|
|
|
135
202
|
agent: config.agent,
|
|
136
203
|
layers: prismResult.layerCount || 0,
|
|
137
204
|
promptSize: prompt.length,
|
|
205
|
+
sizeCheck,
|
|
138
206
|
},
|
|
139
207
|
};
|
|
140
208
|
}
|
|
@@ -176,6 +244,7 @@ function buildPrismSection(config) {
|
|
|
176
244
|
handoff,
|
|
177
245
|
artifacts: state.artifacts || [],
|
|
178
246
|
taskCriteria: [],
|
|
247
|
+
userPrompt: config.additionalContext || null,
|
|
179
248
|
});
|
|
180
249
|
|
|
181
250
|
return {
|
|
@@ -292,6 +361,277 @@ function buildSessionSection(config, resolvedModelInfo = {}) {
|
|
|
292
361
|
return lines.join('\n');
|
|
293
362
|
}
|
|
294
363
|
|
|
364
|
+
// ---------------------------------------------------------------------------
|
|
365
|
+
// Prompt Size Guard
|
|
366
|
+
// ---------------------------------------------------------------------------
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Provider-specific token limits for prompt size validation.
|
|
370
|
+
*/
|
|
371
|
+
const PROVIDER_TOKEN_LIMITS = {
|
|
372
|
+
claude: 200_000,
|
|
373
|
+
gemini: 1_000_000,
|
|
374
|
+
codex: 128_000,
|
|
375
|
+
};
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Validate prompt size against provider-specific limits.
|
|
379
|
+
*
|
|
380
|
+
* @param {string} prompt - The assembled prompt string
|
|
381
|
+
* @param {string} [provider='claude'] - Provider name for limit lookup
|
|
382
|
+
* @returns {{ valid: boolean, level: string, ratio: number, estimatedTokens: number, limit: number, message: string|null }}
|
|
383
|
+
*/
|
|
384
|
+
export function validatePromptSize(prompt, provider = 'claude') {
|
|
385
|
+
const limit = PROVIDER_TOKEN_LIMITS[provider] || PROVIDER_TOKEN_LIMITS.claude;
|
|
386
|
+
const tokens = estimateTokens(prompt);
|
|
387
|
+
const ratio = tokens / limit;
|
|
388
|
+
|
|
389
|
+
if (ratio >= 0.9) {
|
|
390
|
+
return {
|
|
391
|
+
valid: false,
|
|
392
|
+
level: 'error',
|
|
393
|
+
ratio: Math.round(ratio * 100) / 100,
|
|
394
|
+
estimatedTokens: tokens,
|
|
395
|
+
limit,
|
|
396
|
+
message: `Prompt size (${tokens} tokens) exceeds 90% of ${provider} limit (${limit}). Prompt may be truncated or rejected.`,
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
if (ratio >= 0.7) {
|
|
401
|
+
return {
|
|
402
|
+
valid: true,
|
|
403
|
+
level: 'warning',
|
|
404
|
+
ratio: Math.round(ratio * 100) / 100,
|
|
405
|
+
estimatedTokens: tokens,
|
|
406
|
+
limit,
|
|
407
|
+
message: `Prompt size (${tokens} tokens) exceeds 70% of ${provider} limit (${limit}). Consider reducing context.`,
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
return {
|
|
412
|
+
valid: true,
|
|
413
|
+
level: 'ok',
|
|
414
|
+
ratio: Math.round(ratio * 100) / 100,
|
|
415
|
+
estimatedTokens: tokens,
|
|
416
|
+
limit,
|
|
417
|
+
message: null,
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// ---------------------------------------------------------------------------
|
|
422
|
+
// Tech Presets
|
|
423
|
+
// ---------------------------------------------------------------------------
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* Load a tech preset YAML file by stack name.
|
|
427
|
+
*
|
|
428
|
+
* @param {string} stack - Stack identifier (e.g., 'nextjs', 'react-vite')
|
|
429
|
+
* @param {string} projectDir - Project root directory
|
|
430
|
+
* @returns {{ loaded: boolean, preset: object|null, stack: string }}
|
|
431
|
+
*/
|
|
432
|
+
export function loadPreset(stack, projectDir) {
|
|
433
|
+
if (!stack || !projectDir) {
|
|
434
|
+
return { loaded: false, preset: null, stack: stack || '' };
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// Check both deployed (chati.dev/presets/) and package (framework/presets/) locations
|
|
438
|
+
const locations = [
|
|
439
|
+
join(projectDir, 'chati.dev', 'presets'),
|
|
440
|
+
join(projectDir, 'framework', 'presets'),
|
|
441
|
+
];
|
|
442
|
+
|
|
443
|
+
for (const presetsDir of locations) {
|
|
444
|
+
const presetPath = join(presetsDir, `${stack}.yaml`);
|
|
445
|
+
if (!existsSync(presetPath)) continue;
|
|
446
|
+
|
|
447
|
+
try {
|
|
448
|
+
const raw = readFileSync(presetPath, 'utf-8');
|
|
449
|
+
const preset = parsePresetYaml(raw);
|
|
450
|
+
return { loaded: true, preset, stack };
|
|
451
|
+
} catch {
|
|
452
|
+
continue;
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
return { loaded: false, preset: null, stack };
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* Detect which tech preset to load based on project files.
|
|
461
|
+
*
|
|
462
|
+
* @param {string} projectDir - Project root directory
|
|
463
|
+
* @returns {string|null} Detected stack name or null
|
|
464
|
+
*/
|
|
465
|
+
export function detectPreset(projectDir) {
|
|
466
|
+
if (!projectDir) return null;
|
|
467
|
+
|
|
468
|
+
// Check both deployed and package locations
|
|
469
|
+
const locations = [
|
|
470
|
+
join(projectDir, 'chati.dev', 'presets'),
|
|
471
|
+
join(projectDir, 'framework', 'presets'),
|
|
472
|
+
];
|
|
473
|
+
|
|
474
|
+
let presetsDir = null;
|
|
475
|
+
for (const loc of locations) {
|
|
476
|
+
if (existsSync(loc)) { presetsDir = loc; break; }
|
|
477
|
+
}
|
|
478
|
+
if (!presetsDir) return null;
|
|
479
|
+
|
|
480
|
+
// List all preset files
|
|
481
|
+
let presetFiles;
|
|
482
|
+
try {
|
|
483
|
+
presetFiles = readdirSync(presetsDir).filter(f => f.endsWith('.yaml'));
|
|
484
|
+
} catch {
|
|
485
|
+
return null;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// Check package.json dependencies
|
|
489
|
+
const pkgPath = join(projectDir, 'package.json');
|
|
490
|
+
let pkgContent = '';
|
|
491
|
+
if (existsSync(pkgPath)) {
|
|
492
|
+
try { pkgContent = readFileSync(pkgPath, 'utf-8'); } catch { /* ignore */ }
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// Check for detection markers in project root
|
|
496
|
+
for (const file of presetFiles) {
|
|
497
|
+
const raw = readFileSync(join(presetsDir, file), 'utf-8');
|
|
498
|
+
const preset = parsePresetYaml(raw);
|
|
499
|
+
if (!preset.detection || !Array.isArray(preset.detection)) continue;
|
|
500
|
+
|
|
501
|
+
for (const marker of preset.detection) {
|
|
502
|
+
// Check if marker is a file that exists in the project
|
|
503
|
+
if (existsSync(join(projectDir, marker))) {
|
|
504
|
+
return preset.stack || file.replace('.yaml', '');
|
|
505
|
+
}
|
|
506
|
+
// Check if marker is a dependency
|
|
507
|
+
if (pkgContent && pkgContent.includes(`"${marker}"`)) {
|
|
508
|
+
return preset.stack || file.replace('.yaml', '');
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
return null;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/**
|
|
517
|
+
* Parse a preset YAML file into a structured object.
|
|
518
|
+
* Uses lightweight regex parsing consistent with the framework.
|
|
519
|
+
*
|
|
520
|
+
* @param {string} raw - Raw YAML content
|
|
521
|
+
* @returns {object} Parsed preset
|
|
522
|
+
*/
|
|
523
|
+
export function parsePresetYaml(raw) {
|
|
524
|
+
const preset = {
|
|
525
|
+
stack: '',
|
|
526
|
+
displayName: '',
|
|
527
|
+
detection: [],
|
|
528
|
+
conventions: {},
|
|
529
|
+
structure: [],
|
|
530
|
+
rules: [],
|
|
531
|
+
};
|
|
532
|
+
|
|
533
|
+
// stack:
|
|
534
|
+
const stackMatch = raw.match(/^stack:\s*(.+)$/m);
|
|
535
|
+
if (stackMatch) preset.stack = stackMatch[1].trim();
|
|
536
|
+
|
|
537
|
+
// displayName:
|
|
538
|
+
const nameMatch = raw.match(/^displayName:\s*"?([^"\n]+)"?$/m);
|
|
539
|
+
if (nameMatch) preset.displayName = nameMatch[1].trim();
|
|
540
|
+
|
|
541
|
+
// detection: (array)
|
|
542
|
+
const detectionMatch = raw.match(/^detection:\s*\n((?:\s+-\s*.+\n?)*)/m);
|
|
543
|
+
if (detectionMatch) {
|
|
544
|
+
const items = detectionMatch[1].matchAll(/^\s+-\s*"?([^"\n]+)"?\s*$/gm);
|
|
545
|
+
for (const item of items) {
|
|
546
|
+
preset.detection.push(item[1].trim());
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
// conventions: (key-value map)
|
|
551
|
+
const convMatch = raw.match(/^conventions:\s*\n((?:\s+\w[\w]*:\s*.+\n?)*)/m);
|
|
552
|
+
if (convMatch) {
|
|
553
|
+
const pairs = convMatch[1].matchAll(/^\s+(\w[\w]*):\s*"?([^"\n]+)"?\s*$/gm);
|
|
554
|
+
for (const pair of pairs) {
|
|
555
|
+
preset.conventions[pair[1].trim()] = pair[2].trim();
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
// structure: (array of strings)
|
|
560
|
+
const structMatch = raw.match(/^structure:\s*\n((?:\s+-\s*.+\n?)*)/m);
|
|
561
|
+
if (structMatch) {
|
|
562
|
+
const items = structMatch[1].matchAll(/^\s+-\s*"?([^"\n]+)"?\s*$/gm);
|
|
563
|
+
for (const item of items) {
|
|
564
|
+
preset.structure.push(item[1].trim());
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
// rules: (array of objects with id, text, priority)
|
|
569
|
+
const rulesMatch = raw.match(/^rules:\s*\n((?:\s+-.+\n(?:\s+\w.+\n?)*)*)/m);
|
|
570
|
+
if (rulesMatch) {
|
|
571
|
+
const ruleBlocks = rulesMatch[1].split(/(?=\s+-\s+id:)/);
|
|
572
|
+
for (const block of ruleBlocks) {
|
|
573
|
+
const idMatch = block.match(/id:\s*(\S+)/);
|
|
574
|
+
const textMatch = block.match(/text:\s*"?([^"\n]+)"?/);
|
|
575
|
+
const prioMatch = block.match(/priority:\s*(\S+)/);
|
|
576
|
+
if (idMatch && textMatch) {
|
|
577
|
+
preset.rules.push({
|
|
578
|
+
id: idMatch[1],
|
|
579
|
+
text: textMatch[1].trim(),
|
|
580
|
+
priority: prioMatch ? prioMatch[1] : 'normal',
|
|
581
|
+
});
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
return preset;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
/**
|
|
590
|
+
* Build a formatted preset section for prompt injection.
|
|
591
|
+
*
|
|
592
|
+
* @param {object} preset - Parsed preset object
|
|
593
|
+
* @returns {string} Formatted preset section
|
|
594
|
+
*/
|
|
595
|
+
export function buildPresetSection(preset) {
|
|
596
|
+
if (!preset) return '';
|
|
597
|
+
|
|
598
|
+
const lines = [
|
|
599
|
+
'<!-- TECH PRESET -->',
|
|
600
|
+
`## Tech Preset: ${preset.displayName || preset.stack}`,
|
|
601
|
+
'',
|
|
602
|
+
];
|
|
603
|
+
|
|
604
|
+
// Conventions
|
|
605
|
+
if (preset.conventions && Object.keys(preset.conventions).length > 0) {
|
|
606
|
+
lines.push('### Conventions');
|
|
607
|
+
for (const [key, value] of Object.entries(preset.conventions)) {
|
|
608
|
+
lines.push(`- **${key}**: ${value}`);
|
|
609
|
+
}
|
|
610
|
+
lines.push('');
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
// Structure
|
|
614
|
+
if (preset.structure && preset.structure.length > 0) {
|
|
615
|
+
lines.push('### Expected Structure');
|
|
616
|
+
for (const item of preset.structure) {
|
|
617
|
+
lines.push(`- ${item}`);
|
|
618
|
+
}
|
|
619
|
+
lines.push('');
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
// Rules
|
|
623
|
+
if (preset.rules && preset.rules.length > 0) {
|
|
624
|
+
lines.push('### Rules');
|
|
625
|
+
for (const rule of preset.rules) {
|
|
626
|
+
const badge = rule.priority === 'critical' ? '[CRITICAL]' : rule.priority === 'high' ? '[HIGH]' : '';
|
|
627
|
+
lines.push(`- ${badge ? badge + ' ' : ''}${rule.text}`);
|
|
628
|
+
}
|
|
629
|
+
lines.push('');
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
return lines.join('\n');
|
|
633
|
+
}
|
|
634
|
+
|
|
295
635
|
/**
|
|
296
636
|
* Build output format instructions so the agent produces a parseable handoff.
|
|
297
637
|
*/
|
|
@@ -18,6 +18,7 @@ import { buildAgentPrompt } from './prompt-builder.js';
|
|
|
18
18
|
import { spawnTerminal } from './spawner.js';
|
|
19
19
|
import { parseAgentOutput } from './handoff-parser.js';
|
|
20
20
|
import { createCostTracker } from './cost-tracker.js';
|
|
21
|
+
import { getRateLimiter } from './rate-limiter.js';
|
|
21
22
|
import { initCollector, track as telemetryTrack, flush as telemetryFlush } from '../telemetry/collector.js';
|
|
22
23
|
import { sendEvents } from '../telemetry/sender.js';
|
|
23
24
|
import { getTelemetryConfig, isEnabled as isTelemetryEnabled } from '../telemetry/config.js';
|
|
@@ -99,6 +100,20 @@ async function main() {
|
|
|
99
100
|
process.exit(1);
|
|
100
101
|
}
|
|
101
102
|
|
|
103
|
+
// Wait for rate limit slot before spawning
|
|
104
|
+
const spawnProvider = promptResult.provider || args.provider || 'claude';
|
|
105
|
+
const limiter = getRateLimiter(spawnProvider);
|
|
106
|
+
if (!limiter.canSpawn()) {
|
|
107
|
+
const rateLimitStart = Date.now();
|
|
108
|
+
await limiter.waitForSlot();
|
|
109
|
+
const rateLimitWait = Date.now() - rateLimitStart;
|
|
110
|
+
telemetryTrack('rate_limit_wait', {
|
|
111
|
+
agent: args.agent,
|
|
112
|
+
provider: spawnProvider,
|
|
113
|
+
waitMs: rateLimitWait,
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
102
117
|
// Spawn the agent terminal
|
|
103
118
|
const startTime = Date.now();
|
|
104
119
|
let handle;
|
|
@@ -16,11 +16,12 @@
|
|
|
16
16
|
|
|
17
17
|
import { fileURLToPath } from 'url';
|
|
18
18
|
import { buildAgentPrompt } from './prompt-builder.js';
|
|
19
|
-
import { spawnParallelGroup } from './spawner.js';
|
|
19
|
+
import { spawnParallelGroup, spawnTerminal } from './spawner.js';
|
|
20
20
|
import { TerminalMonitor } from './monitor.js';
|
|
21
21
|
import { collectResults, mergeHandoffs, buildConsolidatedHandoff } from './collector.js';
|
|
22
22
|
import { parseAgentOutput } from './handoff-parser.js';
|
|
23
23
|
import { estimateTokens, COST_PER_1K } from './cost-tracker.js';
|
|
24
|
+
import { getRateLimiter } from './rate-limiter.js';
|
|
24
25
|
|
|
25
26
|
// ---------------------------------------------------------------------------
|
|
26
27
|
// CLI argument parsing
|
|
@@ -119,13 +120,29 @@ async function main() {
|
|
|
119
120
|
}
|
|
120
121
|
}
|
|
121
122
|
|
|
122
|
-
//
|
|
123
|
+
// Check rate limit capacity before spawning
|
|
124
|
+
const groupProvider = configs[0]?.provider || 'claude';
|
|
125
|
+
const limiter = getRateLimiter(groupProvider);
|
|
126
|
+
const rateStats = limiter.getStats();
|
|
127
|
+
const availableSlots = rateStats.limit - rateStats.used;
|
|
128
|
+
if (availableSlots < configs.length) {
|
|
129
|
+
console.error(`[chati] Rate limiter: ${availableSlots} slots available for ${configs.length} agents. Spawns may be throttled.`);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Spawn all terminals in parallel (with sequential fallback)
|
|
123
133
|
let group;
|
|
134
|
+
let fallbackUsed = false;
|
|
124
135
|
try {
|
|
125
136
|
group = spawnParallelGroup(configs);
|
|
126
137
|
} catch (err) {
|
|
127
|
-
|
|
128
|
-
|
|
138
|
+
console.error(`[chati] Parallel spawn failed: ${err.message}. Falling back to sequential execution.`);
|
|
139
|
+
try {
|
|
140
|
+
group = await sequentialFallback(configs, timeout);
|
|
141
|
+
fallbackUsed = true;
|
|
142
|
+
} catch (fallbackErr) {
|
|
143
|
+
outputError(`Sequential fallback also failed: ${fallbackErr.message}`);
|
|
144
|
+
process.exit(1);
|
|
145
|
+
}
|
|
129
146
|
}
|
|
130
147
|
|
|
131
148
|
// Monitor until completion
|
|
@@ -218,6 +235,7 @@ async function main() {
|
|
|
218
235
|
timeSaved: elapsed * (agents.length - 1),
|
|
219
236
|
},
|
|
220
237
|
costEstimate: costEstimates,
|
|
238
|
+
fallbackUsed,
|
|
221
239
|
};
|
|
222
240
|
|
|
223
241
|
process.stdout.write(JSON.stringify(output, null, 2) + '\n');
|
|
@@ -228,6 +246,60 @@ async function main() {
|
|
|
228
246
|
// Helpers
|
|
229
247
|
// ---------------------------------------------------------------------------
|
|
230
248
|
|
|
249
|
+
/**
|
|
250
|
+
* Sequential fallback: spawn agents one at a time when parallel spawning fails.
|
|
251
|
+
* Produces the same group structure as spawnParallelGroup for transparent handling.
|
|
252
|
+
*
|
|
253
|
+
* @param {object[]} configs - Agent spawn configurations
|
|
254
|
+
* @param {number} timeout - Per-agent timeout in ms
|
|
255
|
+
* @returns {Promise<{ groupId: string, terminals: object[] }>}
|
|
256
|
+
*/
|
|
257
|
+
async function sequentialFallback(configs, timeout) {
|
|
258
|
+
const groupId = `seq-fallback-${Date.now()}`;
|
|
259
|
+
const terminals = [];
|
|
260
|
+
|
|
261
|
+
for (let i = 0; i < configs.length; i++) {
|
|
262
|
+
const cfg = configs[i];
|
|
263
|
+
const terminal = spawnTerminal({
|
|
264
|
+
agent: cfg.agent,
|
|
265
|
+
taskId: cfg.taskId,
|
|
266
|
+
model: cfg.model,
|
|
267
|
+
provider: cfg.provider,
|
|
268
|
+
prompt: cfg.prompt,
|
|
269
|
+
workingDir: cfg.workingDir,
|
|
270
|
+
timeout: cfg.timeout || timeout,
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
// Wait for this terminal to finish before spawning the next
|
|
274
|
+
await new Promise((resolve) => {
|
|
275
|
+
const timer = setTimeout(() => {
|
|
276
|
+
terminal.kill?.();
|
|
277
|
+
resolve();
|
|
278
|
+
}, (cfg.timeout || timeout) + 5_000);
|
|
279
|
+
|
|
280
|
+
terminal.onExit?.(() => {
|
|
281
|
+
clearTimeout(timer);
|
|
282
|
+
resolve();
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
// If terminal doesn't have onExit, resolve after a short poll
|
|
286
|
+
if (!terminal.onExit) {
|
|
287
|
+
clearTimeout(timer);
|
|
288
|
+
resolve();
|
|
289
|
+
}
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
terminals.push(terminal);
|
|
293
|
+
|
|
294
|
+
// Small delay between spawns to avoid rate limit pressure
|
|
295
|
+
if (i < configs.length - 1) {
|
|
296
|
+
await new Promise(r => setTimeout(r, 500));
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
return { groupId, terminals };
|
|
301
|
+
}
|
|
302
|
+
|
|
231
303
|
/**
|
|
232
304
|
* Determine the next sequential agent after a parallel group.
|
|
233
305
|
* GROUP 1 (detail+architect+ux) → phases
|
|
@@ -256,4 +328,4 @@ if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
|
|
256
328
|
});
|
|
257
329
|
}
|
|
258
330
|
|
|
259
|
-
export { parseArgs, determineNextAgent };
|
|
331
|
+
export { parseArgs, determineNextAgent, sequentialFallback };
|
package/src/terminal/spawner.js
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
import { spawn } from 'child_process';
|
|
11
11
|
import { validateWriteScopes, buildIsolationEnv } from './isolation.js';
|
|
12
12
|
import { getProvider } from './cli-registry.js';
|
|
13
|
+
import { getRateLimiter } from './rate-limiter.js';
|
|
13
14
|
|
|
14
15
|
// ---------------------------------------------------------------------------
|
|
15
16
|
// Constants
|
|
@@ -234,6 +235,10 @@ export function spawnTerminal(config) {
|
|
|
234
235
|
timeout,
|
|
235
236
|
};
|
|
236
237
|
|
|
238
|
+
// Record spawn in rate limiter for throttling
|
|
239
|
+
const providerForRate = config.provider || 'claude';
|
|
240
|
+
getRateLimiter(providerForRate).recordSpawn();
|
|
241
|
+
|
|
237
242
|
// Capture output (capped at ~10MB to prevent unbounded memory growth)
|
|
238
243
|
const MAX_BUFFER_CHUNKS = 10_000;
|
|
239
244
|
if (child.stdout) {
|
|
@@ -297,6 +302,14 @@ export function spawnParallelGroup(configs) {
|
|
|
297
302
|
throw new Error(`Write scope conflicts detected: ${details}`);
|
|
298
303
|
}
|
|
299
304
|
|
|
305
|
+
// Preemptive rate limit capacity check
|
|
306
|
+
const groupProvider = configs[0]?.provider || 'claude';
|
|
307
|
+
const limiter = getRateLimiter(groupProvider);
|
|
308
|
+
const stats = limiter.getStats();
|
|
309
|
+
if (stats.used + configs.length > stats.limit) {
|
|
310
|
+
console.error(`[chati] Rate limit warning: ${stats.used}/${stats.limit} slots used, requesting ${configs.length} more`);
|
|
311
|
+
}
|
|
312
|
+
|
|
300
313
|
const groupId = `group-${Date.now()}`;
|
|
301
314
|
const terminals = configs.map(cfg => spawnTerminal(cfg));
|
|
302
315
|
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Feature flag reader for chati.dev framework.
|
|
3
|
+
*
|
|
4
|
+
* Reads the `features:` section from config.yaml and returns boolean
|
|
5
|
+
* values for each feature toggle. All features default to false when
|
|
6
|
+
* not explicitly set.
|
|
7
|
+
*
|
|
8
|
+
* Uses lightweight regex-based parsing consistent with config-parser.js.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { existsSync, readFileSync } from 'fs';
|
|
12
|
+
import { join } from 'path';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* All known feature flags with their default values.
|
|
16
|
+
* New features start as false (opt-in).
|
|
17
|
+
*/
|
|
18
|
+
const DEFAULTS = {
|
|
19
|
+
hybrid_budget: false,
|
|
20
|
+
anti_dash: false,
|
|
21
|
+
rate_limiter_integration: false,
|
|
22
|
+
l5_keywords: false,
|
|
23
|
+
prompt_size_guard: false,
|
|
24
|
+
ids_decision_engine: false,
|
|
25
|
+
surface_criteria: false,
|
|
26
|
+
parallel_fallback: false,
|
|
27
|
+
tool_mesh: false,
|
|
28
|
+
tech_presets: false,
|
|
29
|
+
doctor_autofix: false,
|
|
30
|
+
brandbook: false,
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Check if a specific feature is enabled.
|
|
35
|
+
*
|
|
36
|
+
* @param {string} projectDir - Project root directory (contains chati.dev/)
|
|
37
|
+
* @param {string} featureName - Feature flag name (e.g., 'hybrid_budget')
|
|
38
|
+
* @returns {boolean} True if feature is enabled, false otherwise
|
|
39
|
+
*/
|
|
40
|
+
export function isFeatureEnabled(projectDir, featureName) {
|
|
41
|
+
const features = getEnabledFeatures(projectDir);
|
|
42
|
+
return features[featureName] ?? DEFAULTS[featureName] ?? false;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Get all feature flags with their current values.
|
|
47
|
+
*
|
|
48
|
+
* @param {string} projectDir - Project root directory (contains chati.dev/)
|
|
49
|
+
* @returns {Record<string, boolean>} Map of feature name to enabled status
|
|
50
|
+
*/
|
|
51
|
+
export function getEnabledFeatures(projectDir) {
|
|
52
|
+
const configPath = join(projectDir, 'chati.dev', 'config.yaml');
|
|
53
|
+
if (!existsSync(configPath)) {
|
|
54
|
+
return { ...DEFAULTS };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const raw = readFileSync(configPath, 'utf-8');
|
|
58
|
+
return parseFeaturesSection(raw);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Parse the features section from raw config.yaml content.
|
|
63
|
+
*
|
|
64
|
+
* @param {string} raw - Raw YAML content
|
|
65
|
+
* @returns {Record<string, boolean>} Parsed feature flags merged with defaults
|
|
66
|
+
*/
|
|
67
|
+
export function parseFeaturesSection(raw) {
|
|
68
|
+
const result = { ...DEFAULTS };
|
|
69
|
+
|
|
70
|
+
// Find the features: block
|
|
71
|
+
const featuresMatch = raw.match(/^features:\s*\n((?:\s+\w[\w]*:\s*.+\n?)*)/m);
|
|
72
|
+
if (!featuresMatch) return result;
|
|
73
|
+
|
|
74
|
+
const block = featuresMatch[1];
|
|
75
|
+
|
|
76
|
+
// Extract each key: value pair
|
|
77
|
+
const linePattern = /^\s+(\w[\w]*):\s*(true|false)\s*$/gm;
|
|
78
|
+
let match;
|
|
79
|
+
while ((match = linePattern.exec(block)) !== null) {
|
|
80
|
+
const key = match[1];
|
|
81
|
+
const value = match[2] === 'true';
|
|
82
|
+
if (key in result) {
|
|
83
|
+
result[key] = value;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return result;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Get the list of known feature flag names.
|
|
92
|
+
*
|
|
93
|
+
* @returns {string[]} Array of feature flag names
|
|
94
|
+
*/
|
|
95
|
+
export function getFeatureNames() {
|
|
96
|
+
return Object.keys(DEFAULTS);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Get default values for all feature flags.
|
|
101
|
+
*
|
|
102
|
+
* @returns {Record<string, boolean>} Default feature flag values
|
|
103
|
+
*/
|
|
104
|
+
export function getDefaults() {
|
|
105
|
+
return { ...DEFAULTS };
|
|
106
|
+
}
|