chati-dev 3.3.1 → 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.
Files changed (39) hide show
  1. package/README.md +4 -4
  2. package/TERMS_OF_USE.md +56 -0
  3. package/framework/config.yaml +23 -3
  4. package/framework/constitution.md +12 -10
  5. package/framework/context/governance.md +2 -0
  6. package/framework/context/root.md +1 -1
  7. package/framework/intelligence/context-engine.md +22 -17
  8. package/package.json +3 -2
  9. package/scripts/doctor/checks/agents.js +77 -0
  10. package/scripts/doctor/checks/constitution.js +41 -0
  11. package/scripts/doctor/checks/domain-alignment.js +58 -0
  12. package/scripts/doctor/checks/prism-layers.js +84 -0
  13. package/scripts/doctor/checks/registry.js +55 -0
  14. package/scripts/doctor/checks/schemas.js +61 -0
  15. package/scripts/doctor/fixes/reference-fix.js +100 -0
  16. package/scripts/doctor/fixes/registry-fix.js +56 -0
  17. package/scripts/doctor/index.js +212 -0
  18. package/scripts/health-check.js +8 -8
  19. package/src/autonomy/surface-criteria.js +226 -0
  20. package/src/context/bracket-tracker.js +44 -13
  21. package/src/context/domain-loader.js +22 -0
  22. package/src/context/engine.js +18 -7
  23. package/src/context/formatter.js +21 -1
  24. package/src/context/layers/l5-keywords.js +53 -0
  25. package/src/installer/templates.js +1 -1
  26. package/src/intelligence/context-status.js +20 -9
  27. package/src/intelligence/decision-engine.js +253 -0
  28. package/src/orchestrator/pipeline-manager.js +80 -9
  29. package/src/telemetry/config.js +1 -1
  30. package/src/telemetry/index.js +1 -1
  31. package/src/telemetry/schema.js +60 -11
  32. package/src/terminal/prompt-builder.js +341 -1
  33. package/src/terminal/run-agent.js +61 -1
  34. package/src/terminal/run-parallel.js +77 -5
  35. package/src/terminal/spawner.js +13 -0
  36. package/src/utils/feature-flags.js +106 -0
  37. package/src/wizard/i18n.js +8 -3
  38. package/src/wizard/index.js +35 -9
  39. package/src/wizard/questions.js +19 -10
@@ -5,9 +5,10 @@
5
5
 
6
6
  import { AGENT_PIPELINE, getNextAgent } from './agent-selector.js';
7
7
  import { calculateBracket, estimateRemaining } from '../context/bracket-tracker.js';
8
- import { track as telemetryTrack, flush as telemetryFlush } from '../telemetry/collector.js';
8
+ import { initCollector, track as telemetryTrack, flush as telemetryFlush } from '../telemetry/collector.js';
9
9
  import { sendEvents } from '../telemetry/sender.js';
10
- import { getTelemetryConfig } from '../telemetry/config.js';
10
+ import { getTelemetryConfig, isEnabled as isTelemetryEnabled } from '../telemetry/config.js';
11
+ import { getCurrentVersion } from '../upgrade/checker.js';
11
12
 
12
13
  /**
13
14
  * Pipeline phases in order.
@@ -39,7 +40,7 @@ const QA_IMPLEMENTATION_THRESHOLD = 95;
39
40
  * @returns {object} Pipeline state
40
41
  */
41
42
  export function initPipeline(options = {}) {
42
- const { isGreenfield = true, mode = 'discover' } = options;
43
+ const { isGreenfield = true, mode = 'discover', targetDir } = options;
43
44
 
44
45
  const agents = {};
45
46
  for (const agentDef of AGENT_PIPELINE) {
@@ -61,9 +62,25 @@ export function initPipeline(options = {}) {
61
62
  };
62
63
  }
63
64
 
65
+ const resolvedDir = targetDir || process.cwd();
66
+
67
+ // Initialize telemetry collector based on project config
68
+ initCollector(isTelemetryEnabled(resolvedDir));
69
+
70
+ const sessionId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
71
+
72
+ telemetryTrack('session_started', {
73
+ sessionId,
74
+ pipelineType: 'full',
75
+ mode,
76
+ });
77
+
64
78
  return {
65
79
  phase: mode,
66
80
  isGreenfield,
81
+ sessionId,
82
+ targetDir: resolvedDir,
83
+ chatiVersion: getCurrentVersion(resolvedDir) || 'unknown',
67
84
  startedAt: new Date().toISOString(),
68
85
  completedAt: null,
69
86
  agents,
@@ -96,7 +113,7 @@ const QUICK_FLOW_AGENTS = ['brief', 'dev', 'qa-implementation', 'devops'];
96
113
  * @returns {object} Pipeline state
97
114
  */
98
115
  export function initQuickFlowPipeline(options = {}) {
99
- const { isGreenfield = false, mode = 'discover' } = options;
116
+ const { isGreenfield = false, mode = 'discover', targetDir } = options;
100
117
 
101
118
  const agents = {};
102
119
  for (const agentName of QUICK_FLOW_AGENTS) {
@@ -108,10 +125,26 @@ export function initQuickFlowPipeline(options = {}) {
108
125
  };
109
126
  }
110
127
 
128
+ const resolvedDir = targetDir || process.cwd();
129
+
130
+ // Initialize telemetry collector based on project config
131
+ initCollector(isTelemetryEnabled(resolvedDir));
132
+
133
+ const sessionId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
134
+
135
+ telemetryTrack('session_started', {
136
+ sessionId,
137
+ pipelineType: 'quick-flow',
138
+ mode,
139
+ });
140
+
111
141
  return {
112
142
  phase: mode,
113
143
  isGreenfield,
114
144
  isQuickFlow: true,
145
+ sessionId,
146
+ targetDir: resolvedDir,
147
+ chatiVersion: getCurrentVersion(resolvedDir) || 'unknown',
115
148
  startedAt: new Date().toISOString(),
116
149
  completedAt: null,
117
150
  agents,
@@ -130,7 +163,7 @@ export function initQuickFlowPipeline(options = {}) {
130
163
  * @returns {object} Pipeline state
131
164
  */
132
165
  export function initStandardFlowPipeline(options = {}) {
133
- const { isGreenfield = false, mode = 'discover' } = options;
166
+ const { isGreenfield = false, mode = 'discover', targetDir } = options;
134
167
 
135
168
  const agents = {};
136
169
  for (const agentName of STANDARD_FLOW_AGENTS) {
@@ -142,10 +175,26 @@ export function initStandardFlowPipeline(options = {}) {
142
175
  };
143
176
  }
144
177
 
178
+ const resolvedDir = targetDir || process.cwd();
179
+
180
+ // Initialize telemetry collector based on project config
181
+ initCollector(isTelemetryEnabled(resolvedDir));
182
+
183
+ const sessionId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
184
+
185
+ telemetryTrack('session_started', {
186
+ sessionId,
187
+ pipelineType: 'standard',
188
+ mode,
189
+ });
190
+
145
191
  return {
146
192
  phase: mode,
147
193
  isGreenfield,
148
194
  isStandardFlow: true,
195
+ sessionId,
196
+ targetDir: resolvedDir,
197
+ chatiVersion: getCurrentVersion(resolvedDir) || 'unknown',
149
198
  startedAt: new Date().toISOString(),
150
199
  completedAt: null,
151
200
  agents,
@@ -245,13 +294,24 @@ export function advancePipeline(pipelineState, completedAgent, results = {}) {
245
294
 
246
295
  // Pipeline complete
247
296
  newState.completedAt = new Date().toISOString();
297
+ const pipelineType1 = newState.isQuickFlow ? 'quick-flow' : newState.isStandardFlow ? 'standard' : 'full';
298
+ const totalDuration1 = Date.now() - new Date(newState.startedAt).getTime();
248
299
  telemetryTrack('pipeline_completed', {
249
- pipelineType: newState.isQuickFlow ? 'quick-flow' : newState.isStandardFlow ? 'standard' : 'full',
250
- totalDuration: Date.now() - new Date(newState.startedAt).getTime(),
300
+ pipelineType: pipelineType1,
301
+ totalDuration: totalDuration1,
251
302
  agentsRun: newState.completedAgents.length,
252
303
  finalStatus: 'completed',
253
304
  deviationCount: (newState.modeTransitions || []).length,
254
305
  });
306
+ telemetryTrack('session_completed', {
307
+ sessionId: newState.sessionId || 'unknown',
308
+ pipelineType: pipelineType1,
309
+ mode: newState.phase === 'deploy' ? 'deploy' : 'build',
310
+ finalStage: newState.phase,
311
+ duration: totalDuration1,
312
+ agentCount: newState.completedAgents.length,
313
+ success: true,
314
+ });
255
315
  const flushedEvents = telemetryFlush();
256
316
  if (flushedEvents.length > 0) {
257
317
  const tConfig = getTelemetryConfig(newState.targetDir || process.cwd());
@@ -306,13 +366,24 @@ export function advancePipeline(pipelineState, completedAgent, results = {}) {
306
366
 
307
367
  // Pipeline complete
308
368
  newState.completedAt = new Date().toISOString();
369
+ const pipelineType2 = newState.isQuickFlow ? 'quick-flow' : newState.isStandardFlow ? 'standard' : 'full';
370
+ const totalDuration2 = Date.now() - new Date(newState.startedAt).getTime();
309
371
  telemetryTrack('pipeline_completed', {
310
- pipelineType: newState.isQuickFlow ? 'quick-flow' : newState.isStandardFlow ? 'standard' : 'full',
311
- totalDuration: Date.now() - new Date(newState.startedAt).getTime(),
372
+ pipelineType: pipelineType2,
373
+ totalDuration: totalDuration2,
312
374
  agentsRun: newState.completedAgents.length,
313
375
  finalStatus: 'completed',
314
376
  deviationCount: (newState.modeTransitions || []).length,
315
377
  });
378
+ telemetryTrack('session_completed', {
379
+ sessionId: newState.sessionId || 'unknown',
380
+ pipelineType: pipelineType2,
381
+ mode: newState.phase === 'deploy' ? 'deploy' : 'build',
382
+ finalStage: newState.phase,
383
+ duration: totalDuration2,
384
+ agentCount: newState.completedAgents.length,
385
+ success: true,
386
+ });
316
387
  const flushedEvents2 = telemetryFlush();
317
388
  if (flushedEvents2.length > 0) {
318
389
  const tConfig2 = getTelemetryConfig(newState.targetDir || process.cwd());
@@ -24,7 +24,7 @@ export function getTelemetryConfig(targetDir) {
24
24
  const configPath = join(targetDir, 'chati.dev', 'config.yaml');
25
25
 
26
26
  const defaults = {
27
- enabled: false,
27
+ enabled: true,
28
28
  anonymousId: null,
29
29
  endpoint: 'https://chati-telemetry.vercel.app/api/events',
30
30
  apiKey: '10b0b54ba4f392fa46379ba778062ab0af5ca61e79609a7dce4aadd660104b56',
@@ -5,7 +5,7 @@
5
5
  * All telemetry is opt-in and anonymous.
6
6
  */
7
7
 
8
- export { TELEMETRY_EVENTS, validateEvent } from './schema.js';
8
+ export { TELEMETRY_EVENTS, validateEvent, EVENT_PROPERTY_RULES } from './schema.js';
9
9
  export { getTelemetryConfig, isEnabled, setEnabled, getAnonymousId } from './config.js';
10
10
  export { initCollector, track, flush, getBufferSize, getStatus } from './collector.js';
11
11
  export { sendEvents, DEFAULT_ENDPOINT } from './sender.js';
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * @fileoverview Telemetry event schema and validation.
3
3
  *
4
- * Defines the 6 event types collected by opt-in telemetry.
4
+ * Defines the 9 event types collected by telemetry.
5
5
  * Zero PII — only anonymous usage metrics.
6
6
  */
7
7
 
@@ -16,6 +16,9 @@ export const TELEMETRY_EVENTS = [
16
16
  'pipeline_completed',
17
17
  'circuit_breaker_triggered',
18
18
  'error_occurred',
19
+ 'session_started',
20
+ 'session_completed',
21
+ 'token_usage',
19
22
  ];
20
23
 
21
24
  // ---------------------------------------------------------------------------
@@ -44,18 +47,48 @@ const EVENT_PROPERTIES = {
44
47
  error_occurred: [
45
48
  'errorType', 'agent', 'provider', 'phase',
46
49
  ],
50
+ session_started: [
51
+ 'sessionId', 'pipelineType', 'mode',
52
+ ],
53
+ session_completed: [
54
+ 'sessionId', 'pipelineType', 'mode', 'finalStage',
55
+ 'duration', 'agentCount', 'success',
56
+ ],
57
+ token_usage: [
58
+ 'sessionId', 'agent', 'provider', 'model',
59
+ 'inputTokens', 'outputTokens', 'totalTokens', 'estimatedCostUsd',
60
+ ],
47
61
  };
48
62
 
49
63
  // ---------------------------------------------------------------------------
50
- // PII blocklist fields that MUST NEVER appear in telemetry
64
+ // Property Rules (required fields + numeric validation for structured types)
51
65
  // ---------------------------------------------------------------------------
52
66
 
53
- const PII_BLOCKLIST = [
54
- 'path', 'filePath', 'fileName', 'directory', 'cwd',
55
- 'apiKey', 'token', 'secret', 'password', 'credential',
56
- 'email', 'username', 'name', 'ip', 'hostname',
57
- 'content', 'code', 'source', 'prompt', 'message',
58
- 'stackTrace', 'stack',
67
+ export const EVENT_PROPERTY_RULES = {
68
+ session_started: {
69
+ required: ['sessionId', 'pipelineType', 'mode'],
70
+ },
71
+ session_completed: {
72
+ required: ['sessionId', 'pipelineType', 'mode', 'finalStage', 'duration', 'agentCount', 'success'],
73
+ numeric: ['duration', 'agentCount'],
74
+ },
75
+ token_usage: {
76
+ required: ['sessionId', 'agent', 'provider', 'model', 'inputTokens', 'outputTokens', 'totalTokens', 'estimatedCostUsd'],
77
+ numeric: ['inputTokens', 'outputTokens', 'totalTokens', 'estimatedCostUsd'],
78
+ },
79
+ };
80
+
81
+ // ---------------------------------------------------------------------------
82
+ // PII patterns — fields that MUST NEVER appear in telemetry
83
+ // Regex patterns aligned with chati-telemetry backend (case-insensitive)
84
+ // ---------------------------------------------------------------------------
85
+
86
+ const PII_PATTERNS = [
87
+ /^(file_?)?path$/i, /^file_?name$/i, /^dir(ectory)?$/i, /^cwd$/i,
88
+ /^api_?key$/i, /^(access_?)?token$/i, /^secret$/i, /^pass(word)?$/i,
89
+ /^cred(ential)?s?$/i, /^e?mail$/i, /^user_?name$/i, /^(full_?)?name$/i,
90
+ /^ip(_?addr(ess)?)?$/i, /^host_?name$/i, /^(source_?)?code$/i,
91
+ /^prompt$/i, /^message$/i, /^content$/i, /^stack_?trace$/i,
59
92
  ];
60
93
 
61
94
  // ---------------------------------------------------------------------------
@@ -81,9 +114,9 @@ export function validateEvent(event) {
81
114
 
82
115
  const props = event.properties || {};
83
116
 
84
- // Check for PII fields
117
+ // Check for PII field names (regex-based, aligned with backend)
85
118
  for (const key of Object.keys(props)) {
86
- if (PII_BLOCKLIST.includes(key)) {
119
+ if (PII_PATTERNS.some(pattern => pattern.test(key))) {
87
120
  errors.push(`PII field detected: "${key}" — must not be included in telemetry`);
88
121
  }
89
122
  }
@@ -91,7 +124,7 @@ export function validateEvent(event) {
91
124
  // Check for PII in values (paths, emails)
92
125
  for (const [key, value] of Object.entries(props)) {
93
126
  if (typeof value === 'string') {
94
- if (value.includes('/Users/') || value.includes('/home/') || value.includes('C:\\Users\\')) {
127
+ if (/\/users\//i.test(value) || /\\users\\/i.test(value) || /\/home\//i.test(value)) {
95
128
  errors.push(`PII detected in "${key}": value contains filesystem path`);
96
129
  }
97
130
  if (value.includes('@') && value.includes('.')) {
@@ -100,5 +133,21 @@ export function validateEvent(event) {
100
133
  }
101
134
  }
102
135
 
136
+ // Property-level validation for event types with rules
137
+ const rules = EVENT_PROPERTY_RULES[event.type];
138
+ if (rules && props) {
139
+ const missing = rules.required.filter(key => !(key in props));
140
+ if (missing.length > 0) {
141
+ errors.push(`Missing required properties for "${event.type}": ${missing.join(', ')}`);
142
+ }
143
+ if (rules.numeric) {
144
+ for (const key of rules.numeric) {
145
+ if (key in props && (typeof props[key] !== 'number' || props[key] < 0)) {
146
+ errors.push(`Property "${key}" must be a non-negative number`);
147
+ }
148
+ }
149
+ }
150
+ }
151
+
103
152
  return { valid: errors.length === 0, errors };
104
153
  }
@@ -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
  */