chati-dev 3.3.2 → 4.0.1

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 (48) hide show
  1. package/README.md +4 -4
  2. package/framework/agents/plan/ux.md +41 -1
  3. package/framework/config.yaml +18 -3
  4. package/framework/constitution.md +18 -10
  5. package/framework/context/governance.md +2 -0
  6. package/framework/context/root.md +1 -1
  7. package/framework/data/entity-registry.yaml +43 -3
  8. package/framework/domains/constitution.yaml +1 -1
  9. package/framework/domains/global.yaml +7 -3
  10. package/framework/domains/workflows/standard-flow.yaml +33 -0
  11. package/framework/i18n/en.yaml +11 -2
  12. package/framework/i18n/es.yaml +11 -2
  13. package/framework/i18n/fr.yaml +11 -2
  14. package/framework/i18n/pt.yaml +11 -2
  15. package/framework/intelligence/context-engine.md +22 -17
  16. package/framework/presets/nextjs.yaml +41 -0
  17. package/framework/presets/node-express.yaml +39 -0
  18. package/framework/presets/react-vite.yaml +37 -0
  19. package/framework/presets/supabase-fullstack.yaml +37 -0
  20. package/framework/templates/brandbook-tmpl.yaml +113 -0
  21. package/framework/templates/component-spec-tmpl.yaml +74 -0
  22. package/framework/templates/design-token-tmpl.yaml +55 -0
  23. package/framework/templates/icon-system-tmpl.yaml +95 -0
  24. package/package.json +1 -1
  25. package/scripts/bundle-framework.js +1 -1
  26. package/scripts/doctor/checks/agents.js +77 -0
  27. package/scripts/doctor/checks/constitution.js +41 -0
  28. package/scripts/doctor/checks/domain-alignment.js +58 -0
  29. package/scripts/doctor/checks/prism-layers.js +84 -0
  30. package/scripts/doctor/checks/registry.js +55 -0
  31. package/scripts/doctor/checks/schemas.js +61 -0
  32. package/scripts/doctor/fixes/reference-fix.js +100 -0
  33. package/scripts/doctor/fixes/registry-fix.js +56 -0
  34. package/scripts/doctor/index.js +212 -0
  35. package/scripts/health-check.js +8 -8
  36. package/src/autonomy/surface-criteria.js +226 -0
  37. package/src/context/bracket-tracker.js +44 -13
  38. package/src/context/domain-loader.js +22 -0
  39. package/src/context/engine.js +18 -7
  40. package/src/context/formatter.js +21 -1
  41. package/src/context/layers/l5-keywords.js +53 -0
  42. package/src/intelligence/context-status.js +20 -9
  43. package/src/intelligence/decision-engine.js +253 -0
  44. package/src/terminal/prompt-builder.js +341 -1
  45. package/src/terminal/run-agent.js +15 -0
  46. package/src/terminal/run-parallel.js +77 -5
  47. package/src/terminal/spawner.js +13 -0
  48. package/src/utils/feature-flags.js +106 -0
@@ -0,0 +1,253 @@
1
+ /**
2
+ * IDS Decision Engine — Intelligent Deduplication & Similarity scoring.
3
+ *
4
+ * Before creating any new artifact, scores existing registry entities
5
+ * for similarity. Enforces Constitution Article XIV: REUSE > ADAPT > CREATE.
6
+ *
7
+ * Algorithm:
8
+ * similarity = (keywordOverlap * 0.6) + (purposeSimilarity * 0.4)
9
+ * >= 90% → REUSE, 60-89% → ADAPT, < 60% → CREATE
10
+ */
11
+
12
+ import { existsSync, readFileSync } from 'fs';
13
+
14
+
15
+ /**
16
+ * @typedef {object} DecisionIntent
17
+ * @property {string[]} keywords - Keywords describing what we want to create
18
+ * @property {string} purpose - One-line description of the intended artifact
19
+ */
20
+
21
+ /**
22
+ * @typedef {object} DecisionResult
23
+ * @property {string} decision - 'REUSE' | 'ADAPT' | 'CREATE'
24
+ * @property {string|null} matchId - The best matching entity ID (null for CREATE)
25
+ * @property {number} similarity - 0-100 similarity score
26
+ * @property {string} reasoning - Human-readable explanation
27
+ * @property {object|null} match - The best matching entity data
28
+ */
29
+
30
+ /**
31
+ * Evaluate a creation intent against the entity registry.
32
+ *
33
+ * @param {DecisionIntent} intent - What we want to create
34
+ * @param {string} registryPath - Absolute path to entity-registry.yaml
35
+ * @returns {DecisionResult}
36
+ */
37
+ export function evaluateDecision(intent, registryPath) {
38
+ if (!intent || !intent.keywords || !intent.purpose) {
39
+ return {
40
+ decision: 'CREATE',
41
+ matchId: null,
42
+ similarity: 0,
43
+ reasoning: 'No intent provided, defaulting to CREATE.',
44
+ match: null,
45
+ };
46
+ }
47
+
48
+ const entities = loadRegistryEntities(registryPath);
49
+ if (entities.length === 0) {
50
+ return {
51
+ decision: 'CREATE',
52
+ matchId: null,
53
+ similarity: 0,
54
+ reasoning: 'Registry empty or not found, defaulting to CREATE.',
55
+ match: null,
56
+ };
57
+ }
58
+
59
+ let bestMatch = null;
60
+ let bestScore = 0;
61
+ let bestId = null;
62
+
63
+ for (const entity of entities) {
64
+ const score = calculateSimilarity(intent, entity);
65
+ if (score > bestScore) {
66
+ bestScore = score;
67
+ bestMatch = entity;
68
+ bestId = entity.id;
69
+ }
70
+ }
71
+
72
+ const similarity = Math.round(bestScore * 100);
73
+ let decision;
74
+ let reasoning;
75
+
76
+ if (similarity >= 90) {
77
+ decision = 'REUSE';
78
+ reasoning = `Entity "${bestId}" is ${similarity}% similar. Reuse as-is (Article XIV).`;
79
+ } else if (similarity >= 60) {
80
+ decision = 'ADAPT';
81
+ reasoning = `Entity "${bestId}" is ${similarity}% similar. Adapt for current needs (adaptability: ${bestMatch?.adaptability ?? 'unknown'}).`;
82
+ } else {
83
+ decision = 'CREATE';
84
+ reasoning = bestId
85
+ ? `Best match "${bestId}" is only ${similarity}% similar. Create new artifact.`
86
+ : 'No sufficiently similar entity found. Create new artifact.';
87
+ }
88
+
89
+ return {
90
+ decision,
91
+ matchId: similarity >= 60 ? bestId : null,
92
+ similarity,
93
+ reasoning,
94
+ match: similarity >= 60 ? bestMatch : null,
95
+ };
96
+ }
97
+
98
+ /**
99
+ * Calculate combined similarity between an intent and an entity.
100
+ *
101
+ * similarity = (keywordOverlap * 0.6) + (purposeSimilarity * 0.4)
102
+ *
103
+ * @param {DecisionIntent} intent
104
+ * @param {{ keywords: string[], purpose: string }} entity
105
+ * @returns {number} 0.0 to 1.0
106
+ */
107
+ export function calculateSimilarity(intent, entity) {
108
+ const kwScore = keywordOverlap(intent.keywords, entity.keywords || []);
109
+ const purposeScore = purposeSimilarity(intent.purpose, entity.purpose || '');
110
+ return (kwScore * 0.6) + (purposeScore * 0.4);
111
+ }
112
+
113
+ /**
114
+ * Calculate keyword overlap using token-based Jaccard similarity.
115
+ *
116
+ * @param {string[]} a - Intent keywords
117
+ * @param {string[]} b - Entity keywords
118
+ * @returns {number} 0.0 to 1.0
119
+ */
120
+ export function keywordOverlap(a, b) {
121
+ if (!a || !b || a.length === 0 || b.length === 0) return 0;
122
+
123
+ const setA = new Set(a.map(k => k.toLowerCase().trim()));
124
+ const setB = new Set(b.map(k => k.toLowerCase().trim()));
125
+
126
+ let intersection = 0;
127
+ for (const k of setA) {
128
+ if (setB.has(k)) intersection++;
129
+ }
130
+
131
+ const union = new Set([...setA, ...setB]).size;
132
+ return union === 0 ? 0 : intersection / union;
133
+ }
134
+
135
+ /**
136
+ * Calculate purpose similarity using token overlap (bag-of-words).
137
+ *
138
+ * @param {string} a - Intent purpose
139
+ * @param {string} b - Entity purpose
140
+ * @returns {number} 0.0 to 1.0
141
+ */
142
+ export function purposeSimilarity(a, b) {
143
+ if (!a || !b) return 0;
144
+
145
+ const tokenize = (text) => {
146
+ return text
147
+ .toLowerCase()
148
+ .replace(/[^a-z0-9\s]/g, '')
149
+ .split(/\s+/)
150
+ .filter(t => t.length > 2); // Skip tiny words
151
+ };
152
+
153
+ const tokensA = new Set(tokenize(a));
154
+ const tokensB = new Set(tokenize(b));
155
+
156
+ if (tokensA.size === 0 || tokensB.size === 0) return 0;
157
+
158
+ let intersection = 0;
159
+ for (const t of tokensA) {
160
+ if (tokensB.has(t)) intersection++;
161
+ }
162
+
163
+ const union = new Set([...tokensA, ...tokensB]).size;
164
+ return union === 0 ? 0 : intersection / union;
165
+ }
166
+
167
+ /**
168
+ * Load and flatten entity registry into a searchable array.
169
+ *
170
+ * @param {string} registryPath - Absolute path to entity-registry.yaml
171
+ * @returns {Array<{ id: string, keywords: string[], purpose: string, path: string, type: string, adaptability: number }>}
172
+ */
173
+ export function loadRegistryEntities(registryPath) {
174
+ if (!registryPath || !existsSync(registryPath)) return [];
175
+
176
+ try {
177
+ const raw = readFileSync(registryPath, 'utf-8');
178
+ return parseRegistryEntities(raw);
179
+ } catch {
180
+ return [];
181
+ }
182
+ }
183
+
184
+ /**
185
+ * Parse registry YAML into flat entity array.
186
+ * Uses simple regex parsing consistent with the codebase pattern.
187
+ *
188
+ * @param {string} raw - Raw YAML content
189
+ * @returns {Array<{ id: string, keywords: string[], purpose: string, path: string, type: string, adaptability: number }>}
190
+ */
191
+ export function parseRegistryEntities(raw) {
192
+ const entities = [];
193
+ const lines = raw.split('\n');
194
+
195
+ let currentCategory = null;
196
+ let currentEntity = null;
197
+ let currentData = {};
198
+
199
+ for (const line of lines) {
200
+ // Category header (agents:, templates:, etc.) — 2-space indent under entities:
201
+ const categoryMatch = line.match(/^ {2}(\w[\w-]*):\s*$/);
202
+ if (categoryMatch && !line.match(/^\s{4}/)) {
203
+ // Save previous entity
204
+ if (currentEntity && currentData.purpose) {
205
+ entities.push({ id: `${currentCategory}/${currentEntity}`, ...currentData });
206
+ }
207
+ currentCategory = categoryMatch[1];
208
+ currentEntity = null;
209
+ currentData = {};
210
+ continue;
211
+ }
212
+
213
+ // Entity header (e.g., " orchestrator:") — 4-space indent
214
+ const entityMatch = line.match(/^ {4}([\w][\w-]*):\s*$/);
215
+ if (entityMatch) {
216
+ // Save previous entity
217
+ if (currentEntity && currentData.purpose) {
218
+ entities.push({ id: `${currentCategory}/${currentEntity}`, ...currentData });
219
+ }
220
+ currentEntity = entityMatch[1];
221
+ currentData = { keywords: [], purpose: '', path: '', type: '', adaptability: 0.5 };
222
+ continue;
223
+ }
224
+
225
+ if (!currentEntity) continue;
226
+
227
+ // Entity fields (6-space indent)
228
+ const pathMatch = line.match(/^\s{6}path:\s*(.+)/);
229
+ if (pathMatch) { currentData.path = pathMatch[1].trim(); continue; }
230
+
231
+ const typeMatch = line.match(/^\s{6}type:\s*(.+)/);
232
+ if (typeMatch) { currentData.type = typeMatch[1].trim(); continue; }
233
+
234
+ const purposeMatch = line.match(/^\s{6}purpose:\s*"?([^"]*)"?\s*$/);
235
+ if (purposeMatch) { currentData.purpose = purposeMatch[1].trim(); continue; }
236
+
237
+ const adaptMatch = line.match(/^\s{6}adaptability:\s*([\d.]+)/);
238
+ if (adaptMatch) { currentData.adaptability = parseFloat(adaptMatch[1]); continue; }
239
+
240
+ const kwMatch = line.match(/^\s{6}keywords:\s*\[(.+)\]/);
241
+ if (kwMatch) {
242
+ currentData.keywords = kwMatch[1].split(',').map(k => k.trim().replace(/^['"]|['"]$/g, ''));
243
+ continue;
244
+ }
245
+ }
246
+
247
+ // Save last entity
248
+ if (currentEntity && currentData.purpose) {
249
+ entities.push({ id: `${currentCategory}/${currentEntity}`, ...currentData });
250
+ }
251
+
252
+ return entities;
253
+ }
@@ -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;