chati-dev 3.2.5 → 3.3.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 (98) hide show
  1. package/LICENSE +96 -0
  2. package/bin/chati.js +46 -0
  3. package/framework/agents/build/dev.md +122 -1
  4. package/framework/agents/deploy/devops.md +128 -3
  5. package/framework/agents/discover/brief.md +77 -15
  6. package/framework/agents/discover/brownfield-wu.md +2 -2
  7. package/framework/agents/discover/greenfield-wu.md +3 -3
  8. package/framework/agents/plan/architect.md +2 -2
  9. package/framework/agents/plan/detail.md +3 -3
  10. package/framework/agents/plan/phases.md +127 -2
  11. package/framework/agents/plan/tasks.md +127 -2
  12. package/framework/agents/plan/ux.md +269 -22
  13. package/framework/agents/quality/qa-implementation.md +172 -8
  14. package/framework/agents/quality/qa-planning.md +147 -2
  15. package/framework/config.yaml +9 -5
  16. package/framework/constitution.md +7 -1
  17. package/framework/context/quality.md +1 -1
  18. package/framework/context/root.md +1 -1
  19. package/framework/hooks/constitution-guard.js +18 -2
  20. package/framework/hooks/mode-governance.js +3 -3
  21. package/framework/hooks/read-protection.js +10 -2
  22. package/framework/i18n/en.yaml +6 -0
  23. package/framework/i18n/es.yaml +6 -0
  24. package/framework/i18n/fr.yaml +6 -0
  25. package/framework/i18n/pt.yaml +6 -0
  26. package/framework/orchestrator/chati.md +102 -6
  27. package/framework/schemas/task.schema.json +1 -1
  28. package/framework/tasks/architect-dep-audit.md +128 -0
  29. package/framework/tasks/architect-stack-selection.md +28 -0
  30. package/framework/workflows/brownfield-fullstack.yaml +2 -2
  31. package/framework/workflows/brownfield-service.yaml +2 -2
  32. package/framework/workflows/brownfield-ui.yaml +2 -2
  33. package/framework/workflows/greenfield-fullstack.yaml +6 -2
  34. package/framework/workflows/quick-flow.yaml +7 -5
  35. package/framework/workflows/standard-flow.yaml +171 -0
  36. package/package.json +4 -2
  37. package/src/api/index.js +129 -0
  38. package/src/autonomy/build-loop.js +93 -6
  39. package/src/autonomy/build-state.js +20 -2
  40. package/src/autonomy/cause-analyzer.js +177 -0
  41. package/src/autonomy/escalation.js +214 -0
  42. package/src/autonomy/safety-net.js +23 -5
  43. package/src/autonomy/worktree-manager.js +245 -0
  44. package/src/config/agent-customizer.js +227 -0
  45. package/src/config/ide-configs.js +57 -27
  46. package/src/decision/analyzer.js +148 -0
  47. package/src/decision/registry-healer.js +38 -21
  48. package/src/extensions/loader.js +151 -0
  49. package/src/extensions/registry.js +134 -0
  50. package/src/gates/circuit-breaker.js +32 -0
  51. package/src/gates/g3-implementation.js +30 -4
  52. package/src/gates/g4-qa-implementation.js +34 -5
  53. package/src/gates/gate-base.js +9 -0
  54. package/src/health/auto-fix.js +216 -0
  55. package/src/installer/core.js +24 -11
  56. package/src/installer/provider-overlay.js +82 -0
  57. package/src/installer/templates.js +22 -10
  58. package/src/installer/transaction.js +3 -2
  59. package/src/installer/validator.js +74 -0
  60. package/src/intelligence/context-status.js +9 -5
  61. package/src/intelligence/document-sharder.js +221 -0
  62. package/src/intelligence/elicitation.js +265 -0
  63. package/src/intelligence/timeline.js +5 -0
  64. package/src/memory/gotchas.js +78 -2
  65. package/src/merger/semantic-merger.js +292 -0
  66. package/src/orchestrator/agent-selector.js +20 -0
  67. package/src/orchestrator/handoff-engine.js +77 -0
  68. package/src/orchestrator/index.js +0 -8
  69. package/src/orchestrator/intent-classifier.js +182 -0
  70. package/src/orchestrator/pipeline-manager.js +125 -1
  71. package/src/orchestrator/session-manager.js +164 -2
  72. package/src/quality/metrics-collector.js +283 -0
  73. package/src/quality/test-runner.js +368 -0
  74. package/src/telemetry/collector.js +83 -0
  75. package/src/telemetry/config.js +119 -0
  76. package/src/telemetry/index.js +11 -0
  77. package/src/telemetry/schema.js +104 -0
  78. package/src/telemetry/sender.js +60 -0
  79. package/src/terminal/cli-registry.js +7 -1
  80. package/src/terminal/cost-tracker.js +197 -0
  81. package/src/terminal/handoff-parser.js +61 -4
  82. package/src/terminal/prompt-builder.js +56 -18
  83. package/src/terminal/rate-limiter.js +172 -0
  84. package/src/terminal/run-agent.js +39 -0
  85. package/src/terminal/run-parallel.js +22 -1
  86. package/src/terminal/spawner.js +181 -3
  87. package/src/upgrade/migrator.js +2 -2
  88. package/src/utils/event-bus.js +126 -0
  89. package/src/utils/file-lock.js +291 -0
  90. package/src/utils/schema-validator.js +226 -0
  91. package/src/wizard/i18n.js +11 -0
  92. package/src/wizard/index.js +42 -20
  93. package/src/wizard/questions.js +200 -39
  94. package/src/autonomy/execution-profile.js +0 -151
  95. package/src/intelligence/file-tracker.js +0 -117
  96. package/src/memory/gotchas-auto-capture.js +0 -253
  97. package/src/orchestrator/pipeline-state.js +0 -223
  98. package/src/terminal/wave-analyzer.js +0 -143
@@ -139,6 +139,154 @@ export function getTransitiveDependents(graph, entityPath) {
139
139
  return result;
140
140
  }
141
141
 
142
+ /**
143
+ * Score a decision option based on multiple criteria using TF-IDF-inspired
144
+ * keyword relevance scoring.
145
+ *
146
+ * @param {{ name: string, description?: string, keywords?: string[] }} option
147
+ * @param {{ requirements?: string[], constraints?: string[], existingEntities?: string[] }} context
148
+ * @returns {{ score: number, alignment: number, complexity: number, reuse: number, breakdown: object }}
149
+ */
150
+ export function scoreDecision(option, context = {}) {
151
+ if (!option || !option.name) {
152
+ return { score: 0, alignment: 0, complexity: 0, reuse: 0, breakdown: {} };
153
+ }
154
+
155
+ const requirements = context.requirements || [];
156
+ const constraints = context.constraints || [];
157
+ const existingEntities = context.existingEntities || [];
158
+
159
+ // 1. Alignment score — how well does option match requirements?
160
+ const optionText = `${option.name} ${option.description || ''} ${(option.keywords || []).join(' ')}`.toLowerCase();
161
+ const alignment = computeRelevance(optionText, requirements);
162
+
163
+ // 2. Complexity score (inverse — lower complexity = higher score)
164
+ const complexitySignals = ['microservice', 'distributed', 'multi-tenant', 'real-time', 'concurrent', 'migration'];
165
+ let complexityCount = 0;
166
+ for (const signal of complexitySignals) {
167
+ if (optionText.includes(signal)) {
168
+ complexityCount++;
169
+ }
170
+ }
171
+ const complexity = Math.max(0, 1 - complexityCount * 0.15);
172
+
173
+ // 3. Reuse score — overlap with existing entities (decision priority: REUSE > ADAPT > CREATE)
174
+ let reuse = 0;
175
+ if (existingEntities.length > 0) {
176
+ const optionWords = new Set(optionText.split(/\s+/));
177
+ let matchCount = 0;
178
+ for (const entity of existingEntities) {
179
+ const entityWords = entity.toLowerCase().split(/[\s/.-]+/);
180
+ for (const word of entityWords) {
181
+ if (word.length > 3 && optionWords.has(word)) {
182
+ matchCount++;
183
+ break;
184
+ }
185
+ }
186
+ }
187
+ reuse = existingEntities.length > 0 ? matchCount / existingEntities.length : 0;
188
+ }
189
+
190
+ // 4. Constraint satisfaction
191
+ let constraintScore = 1;
192
+ if (constraints.length > 0) {
193
+ let satisfied = 0;
194
+ for (const constraint of constraints) {
195
+ const constraintWords = constraint.toLowerCase().split(/\s+/);
196
+ const hasMatch = constraintWords.some(w => w.length > 3 && optionText.includes(w));
197
+ if (hasMatch) satisfied++;
198
+ }
199
+ constraintScore = constraints.length > 0 ? satisfied / constraints.length : 1;
200
+ }
201
+
202
+ // Weighted final score
203
+ const score = Math.round(
204
+ (alignment * 0.4 + complexity * 0.2 + reuse * 0.25 + constraintScore * 0.15) * 100
205
+ ) / 100;
206
+
207
+ return {
208
+ score,
209
+ alignment: Math.round(alignment * 100) / 100,
210
+ complexity: Math.round(complexity * 100) / 100,
211
+ reuse: Math.round(reuse * 100) / 100,
212
+ breakdown: {
213
+ alignment: { weight: 0.4, raw: alignment },
214
+ complexity: { weight: 0.2, raw: complexity },
215
+ reuse: { weight: 0.25, raw: reuse },
216
+ constraints: { weight: 0.15, raw: constraintScore },
217
+ },
218
+ };
219
+ }
220
+
221
+ // ---------------------------------------------------------------------------
222
+ // TF-IDF Helpers
223
+ // ---------------------------------------------------------------------------
224
+
225
+ /**
226
+ * Compute TF-IDF-inspired relevance between text and a set of documents.
227
+ *
228
+ * @param {string} text - Source text
229
+ * @param {string[]} documents - Reference documents (requirements, etc.)
230
+ * @returns {number} Relevance score (0-1)
231
+ */
232
+ function computeRelevance(text, documents) {
233
+ if (!text || documents.length === 0) return 0;
234
+
235
+ const textWords = tokenize(text);
236
+ if (textWords.length === 0) return 0;
237
+
238
+ // Build document frequency map
239
+ const docCount = documents.length;
240
+ const df = new Map();
241
+
242
+ for (const doc of documents) {
243
+ const words = new Set(tokenize(doc.toLowerCase()));
244
+ for (const word of words) {
245
+ df.set(word, (df.get(word) || 0) + 1);
246
+ }
247
+ }
248
+
249
+ // Calculate TF-IDF score for each text word
250
+ const textWordSet = new Set(textWords);
251
+ let totalScore = 0;
252
+
253
+ for (const word of textWordSet) {
254
+ const termFreq = textWords.filter(w => w === word).length / textWords.length;
255
+ const docFreq = df.get(word) || 0;
256
+
257
+ if (docFreq > 0) {
258
+ // IDF = log(N / df) — boost rare terms
259
+ const idf = Math.log((docCount + 1) / (docFreq + 1)) + 1;
260
+ totalScore += termFreq * idf;
261
+ }
262
+ }
263
+
264
+ // Normalize to 0-1 range
265
+ const maxPossible = Math.log(docCount + 1) + 1;
266
+ return maxPossible > 0 ? Math.min(totalScore / maxPossible, 1) : 0;
267
+ }
268
+
269
+ /**
270
+ * Tokenize text into meaningful words (skip stopwords and short tokens).
271
+ *
272
+ * @param {string} text
273
+ * @returns {string[]}
274
+ */
275
+ function tokenize(text) {
276
+ const stopwords = new Set([
277
+ 'the', 'a', 'an', 'and', 'or', 'but', 'in', 'on', 'at', 'to', 'for',
278
+ 'of', 'with', 'by', 'is', 'are', 'was', 'were', 'be', 'been', 'being',
279
+ 'have', 'has', 'had', 'do', 'does', 'did', 'will', 'would', 'could',
280
+ 'should', 'may', 'might', 'shall', 'can', 'this', 'that', 'these',
281
+ 'those', 'it', 'its', 'not', 'no', 'from', 'as', 'into', 'all',
282
+ ]);
283
+
284
+ return text
285
+ .toLowerCase()
286
+ .split(/[\s,.;:!?()[\]{}"'`/\\-]+/)
287
+ .filter(w => w.length > 2 && !stopwords.has(w));
288
+ }
289
+
142
290
  /**
143
291
  * Flatten entity registry into array of entities.
144
292
  * @private
@@ -10,6 +10,20 @@ import { join, isAbsolute } from 'path';
10
10
  import yaml from 'js-yaml';
11
11
  import { detectNewEntities, detectRemovedEntities, generateEntityMeta } from './registry-updater.js';
12
12
 
13
+ /**
14
+ * Load and parse the entity registry YAML once.
15
+ * @param {string} projectDir
16
+ * @returns {{ registry: object|null, registryPath: string }}
17
+ */
18
+ function loadRegistry(projectDir) {
19
+ const registryPath = join(projectDir, 'chati.dev', 'entity-registry.yaml');
20
+ if (!existsSync(registryPath)) {
21
+ return { registry: null, registryPath };
22
+ }
23
+ const registry = yaml.load(readFileSync(registryPath, 'utf8'));
24
+ return { registry, registryPath };
25
+ }
26
+
13
27
  /**
14
28
  * Run all healing rules and return diagnosis.
15
29
  * @param {string} projectDir
@@ -22,6 +36,9 @@ export function healRegistry(projectDir, options = {}) {
22
36
  const issues = [];
23
37
  const fixes = [];
24
38
 
39
+ // Load registry once for rules 3-6
40
+ const { registry } = loadRegistry(projectDir);
41
+
25
42
  // Rule 1: Missing entities
26
43
  const missingIssues = detectMissingEntities(projectDir);
27
44
  issues.push(...missingIssues);
@@ -50,7 +67,7 @@ export function healRegistry(projectDir, options = {}) {
50
67
  }
51
68
 
52
69
  // Rule 3: Stale metadata
53
- const staleIssues = detectStaleMetadata(projectDir);
70
+ const staleIssues = detectStaleMetadata(projectDir, registry);
54
71
  issues.push(...staleIssues);
55
72
  if (autoFix) {
56
73
  staleIssues.forEach(issue => {
@@ -64,7 +81,7 @@ export function healRegistry(projectDir, options = {}) {
64
81
  }
65
82
 
66
83
  // Rule 4: Duplicates
67
- const duplicateIssues = detectDuplicates(projectDir);
84
+ const duplicateIssues = detectDuplicates(projectDir, registry);
68
85
  issues.push(...duplicateIssues);
69
86
  if (autoFix) {
70
87
  duplicateIssues.forEach(issue => {
@@ -78,7 +95,7 @@ export function healRegistry(projectDir, options = {}) {
78
95
  }
79
96
 
80
97
  // Rule 5: Invalid paths
81
- const invalidPathIssues = detectInvalidPaths(projectDir);
98
+ const invalidPathIssues = detectInvalidPaths(projectDir, registry);
82
99
  issues.push(...invalidPathIssues);
83
100
  if (autoFix) {
84
101
  invalidPathIssues.forEach(issue => {
@@ -92,7 +109,7 @@ export function healRegistry(projectDir, options = {}) {
92
109
  }
93
110
 
94
111
  // Rule 6: Count mismatch
95
- const countIssues = detectCountMismatch(projectDir);
112
+ const countIssues = detectCountMismatch(projectDir, registry);
96
113
  issues.push(...countIssues);
97
114
  if (autoFix) {
98
115
  countIssues.forEach(issue => {
@@ -158,15 +175,15 @@ export function detectOrphanedEntries(projectDir) {
158
175
  /**
159
176
  * Rule 3: Stale metadata (file modified after registry entry).
160
177
  * @param {string} projectDir
178
+ * @param {object} [preloaded] - Pre-loaded registry object (avoids redundant file read)
161
179
  * @returns {object[]} Issues found
162
180
  */
163
- export function detectStaleMetadata(projectDir) {
164
- const registryPath = join(projectDir, 'chati.dev', 'entity-registry.yaml');
165
- if (!existsSync(registryPath)) {
181
+ export function detectStaleMetadata(projectDir, preloaded) {
182
+ const registry = preloaded || loadRegistry(projectDir).registry;
183
+ if (!registry) {
166
184
  return [];
167
185
  }
168
186
 
169
- const registry = yaml.load(readFileSync(registryPath, 'utf8'));
170
187
  const lastUpdated = registry.metadata?.last_updated
171
188
  ? new Date(registry.metadata.last_updated)
172
189
  : new Date(0);
@@ -202,15 +219,15 @@ export function detectStaleMetadata(projectDir) {
202
219
  /**
203
220
  * Rule 4: Duplicate entries (same path appears twice).
204
221
  * @param {string} projectDir
222
+ * @param {object} [preloaded] - Pre-loaded registry object (avoids redundant file read)
205
223
  * @returns {object[]} Issues found
206
224
  */
207
- export function detectDuplicates(projectDir) {
208
- const registryPath = join(projectDir, 'chati.dev', 'entity-registry.yaml');
209
- if (!existsSync(registryPath)) {
225
+ export function detectDuplicates(projectDir, preloaded) {
226
+ const registry = preloaded || loadRegistry(projectDir).registry;
227
+ if (!registry) {
210
228
  return [];
211
229
  }
212
230
 
213
- const registry = yaml.load(readFileSync(registryPath, 'utf8'));
214
231
  const pathCounts = new Map();
215
232
  const issues = [];
216
233
 
@@ -242,15 +259,15 @@ export function detectDuplicates(projectDir) {
242
259
  /**
243
260
  * Rule 5: Invalid paths (malformed or absolute paths).
244
261
  * @param {string} projectDir
262
+ * @param {object} [preloaded] - Pre-loaded registry object (avoids redundant file read)
245
263
  * @returns {object[]} Issues found
246
264
  */
247
- export function detectInvalidPaths(projectDir) {
248
- const registryPath = join(projectDir, 'chati.dev', 'entity-registry.yaml');
249
- if (!existsSync(registryPath)) {
265
+ export function detectInvalidPaths(projectDir, preloaded) {
266
+ const registry = preloaded || loadRegistry(projectDir).registry;
267
+ if (!registry) {
250
268
  return [];
251
269
  }
252
270
 
253
- const registry = yaml.load(readFileSync(registryPath, 'utf8'));
254
271
  const issues = [];
255
272
 
256
273
  Object.values(registry.entities || {}).forEach(entities => {
@@ -291,15 +308,15 @@ export function detectInvalidPaths(projectDir) {
291
308
  /**
292
309
  * Rule 6: Entity count mismatch (metadata.entity_count != actual).
293
310
  * @param {string} projectDir
311
+ * @param {object} [preloaded] - Pre-loaded registry object (avoids redundant file read)
294
312
  * @returns {object[]} Issues found
295
313
  */
296
- export function detectCountMismatch(projectDir) {
297
- const registryPath = join(projectDir, 'chati.dev', 'entity-registry.yaml');
298
- if (!existsSync(registryPath)) {
314
+ export function detectCountMismatch(projectDir, preloaded) {
315
+ const registry = preloaded || loadRegistry(projectDir).registry;
316
+ if (!registry) {
299
317
  return [];
300
318
  }
301
319
 
302
- const registry = yaml.load(readFileSync(registryPath, 'utf8'));
303
320
  let actualCount = 0;
304
321
 
305
322
  Object.values(registry.entities || {}).forEach(entities => {
@@ -346,7 +363,7 @@ export function applyFixes(projectDir, fixes) {
346
363
  const backupPath = join(backupDir, `entity-registry-${timestamp}.yaml`);
347
364
  copyFileSync(registryPath, backupPath);
348
365
 
349
- // Load registry
366
+ // Load registry (fresh read for modification — applyFixes is the write path)
350
367
  const registry = yaml.load(readFileSync(registryPath, 'utf8'));
351
368
 
352
369
  let applied = 0;
@@ -0,0 +1,151 @@
1
+ /**
2
+ * @fileoverview Extension loader for chati.dev.
3
+ *
4
+ * Discovers and loads project-level extensions from
5
+ * `chati.dev/extensions/*.js`, validates them, and registers
6
+ * them in the extension registry.
7
+ *
8
+ * Constitution Article XIV — Framework Registry Governance.
9
+ */
10
+
11
+ import { existsSync, readdirSync } from 'fs';
12
+ import { join, extname, basename } from 'path';
13
+ import { registerExtension, EXTENSION_POINTS } from './registry.js';
14
+
15
+ // ---------------------------------------------------------------------------
16
+ // Constants
17
+ // ---------------------------------------------------------------------------
18
+
19
+ /** Directory relative to project root where extensions are stored. */
20
+ const EXTENSIONS_DIR = 'chati.dev/extensions';
21
+
22
+ /** Required fields in an extension module's default export. */
23
+ const REQUIRED_FIELDS = ['name', 'point', 'handler'];
24
+
25
+ // ---------------------------------------------------------------------------
26
+ // Public API
27
+ // ---------------------------------------------------------------------------
28
+
29
+ /**
30
+ * Validate an extension object has the required shape.
31
+ *
32
+ * @param {object} ext - Extension object to validate
33
+ * @returns {{ valid: boolean, errors: string[] }}
34
+ */
35
+ export function validateExtension(ext) {
36
+ const errors = [];
37
+
38
+ if (!ext || typeof ext !== 'object') {
39
+ errors.push('Extension must be a non-null object');
40
+ return { valid: false, errors };
41
+ }
42
+
43
+ for (const field of REQUIRED_FIELDS) {
44
+ if (ext[field] === undefined || ext[field] === null) {
45
+ errors.push(`Missing required field: "${field}"`);
46
+ }
47
+ }
48
+
49
+ if (ext.name !== undefined && typeof ext.name !== 'string') {
50
+ errors.push('name must be a string');
51
+ }
52
+
53
+ if (ext.point !== undefined) {
54
+ const validPoints = Object.values(EXTENSION_POINTS);
55
+ if (!validPoints.includes(ext.point)) {
56
+ errors.push(`Invalid extension point: "${ext.point}". Valid: ${validPoints.join(', ')}`);
57
+ }
58
+ }
59
+
60
+ if (ext.handler !== undefined && typeof ext.handler !== 'function') {
61
+ errors.push('handler must be a function');
62
+ }
63
+
64
+ if (ext.priority !== undefined && typeof ext.priority !== 'number') {
65
+ errors.push('priority must be a number');
66
+ }
67
+
68
+ return { valid: errors.length === 0, errors };
69
+ }
70
+
71
+ /**
72
+ * Discover extension files in the project extensions directory.
73
+ *
74
+ * @param {string} projectDir - Project root directory
75
+ * @returns {{ files: string[], dir: string }}
76
+ */
77
+ export function discoverExtensions(projectDir) {
78
+ if (!projectDir) {
79
+ return { files: [], dir: '' };
80
+ }
81
+
82
+ const extDir = join(projectDir, EXTENSIONS_DIR);
83
+
84
+ if (!existsSync(extDir)) {
85
+ return { files: [], dir: extDir };
86
+ }
87
+
88
+ try {
89
+ const entries = readdirSync(extDir);
90
+ const jsFiles = entries
91
+ .filter(f => extname(f) === '.js')
92
+ .map(f => join(extDir, f));
93
+
94
+ return { files: jsFiles, dir: extDir };
95
+ } catch {
96
+ return { files: [], dir: extDir };
97
+ }
98
+ }
99
+
100
+ /**
101
+ * Load and register all extensions from a project directory.
102
+ *
103
+ * @param {string} projectDir - Project root directory
104
+ * @returns {Promise<{ loaded: number, failed: number, errors: Array<{ file: string, error: string }> }>}
105
+ */
106
+ export async function loadProjectExtensions(projectDir) {
107
+ const { files } = discoverExtensions(projectDir);
108
+ let loaded = 0;
109
+ let failed = 0;
110
+ const errors = [];
111
+
112
+ for (const filePath of files) {
113
+ try {
114
+ const mod = await import(filePath);
115
+ const ext = mod.default || mod;
116
+
117
+ const validation = validateExtension(ext);
118
+ if (!validation.valid) {
119
+ failed++;
120
+ errors.push({
121
+ file: basename(filePath),
122
+ error: validation.errors.join('; '),
123
+ });
124
+ continue;
125
+ }
126
+
127
+ const result = registerExtension(ext.point, ext.handler, {
128
+ name: ext.name,
129
+ priority: ext.priority || 0,
130
+ });
131
+
132
+ if (result.registered) {
133
+ loaded++;
134
+ } else {
135
+ failed++;
136
+ errors.push({
137
+ file: basename(filePath),
138
+ error: result.error || 'Registration failed',
139
+ });
140
+ }
141
+ } catch (err) {
142
+ failed++;
143
+ errors.push({
144
+ file: basename(filePath),
145
+ error: err.message,
146
+ });
147
+ }
148
+ }
149
+
150
+ return { loaded, failed, errors };
151
+ }
@@ -0,0 +1,134 @@
1
+ /**
2
+ * @fileoverview Extension point registry for chati.dev.
3
+ *
4
+ * Provides a mechanism for registering, retrieving, and
5
+ * executing extension handlers at defined extension points.
6
+ *
7
+ * Constitution Article XIV — Framework Registry Governance.
8
+ */
9
+
10
+ // ---------------------------------------------------------------------------
11
+ // Constants
12
+ // ---------------------------------------------------------------------------
13
+
14
+ /** Available extension points in the pipeline lifecycle. */
15
+ export const EXTENSION_POINTS = {
16
+ PRE_AGENT: 'pre_agent',
17
+ POST_AGENT: 'post_agent',
18
+ PRE_BUILD: 'pre_build',
19
+ POST_BUILD: 'post_build',
20
+ HEALTH_CHECK: 'health_check',
21
+ CUSTOM_GATE: 'custom_gate',
22
+ };
23
+
24
+ // ---------------------------------------------------------------------------
25
+ // Internal State
26
+ // ---------------------------------------------------------------------------
27
+
28
+ /** @type {Map<string, Array<{ handler: Function, name: string, priority: number }>>} */
29
+ const _registry = new Map();
30
+
31
+ // Initialize registry with empty arrays for each point
32
+ for (const point of Object.values(EXTENSION_POINTS)) {
33
+ _registry.set(point, []);
34
+ }
35
+
36
+ // ---------------------------------------------------------------------------
37
+ // Public API
38
+ // ---------------------------------------------------------------------------
39
+
40
+ /**
41
+ * Register an extension handler at a specific point.
42
+ *
43
+ * @param {string} point - Extension point (from EXTENSION_POINTS)
44
+ * @param {Function} handler - Handler function
45
+ * @param {{ name?: string, priority?: number }} [options={}]
46
+ * @returns {{ registered: boolean, error?: string }}
47
+ */
48
+ export function registerExtension(point, handler, options = {}) {
49
+ if (!Object.values(EXTENSION_POINTS).includes(point)) {
50
+ return { registered: false, error: `Unknown extension point: ${point}` };
51
+ }
52
+
53
+ if (typeof handler !== 'function') {
54
+ return { registered: false, error: 'Handler must be a function' };
55
+ }
56
+
57
+ const name = options.name || handler.name || 'anonymous';
58
+ const priority = options.priority || 0;
59
+
60
+ const extensions = _registry.get(point);
61
+ extensions.push({ handler, name, priority });
62
+
63
+ // Sort by priority (higher first)
64
+ extensions.sort((a, b) => b.priority - a.priority);
65
+
66
+ return { registered: true };
67
+ }
68
+
69
+ /**
70
+ * Get all extensions registered at a point.
71
+ *
72
+ * @param {string} point - Extension point
73
+ * @returns {Array<{ handler: Function, name: string, priority: number }>}
74
+ */
75
+ export function getExtensions(point) {
76
+ return _registry.get(point) || [];
77
+ }
78
+
79
+ /**
80
+ * Execute all extensions at a point with given context.
81
+ *
82
+ * @param {string} point - Extension point
83
+ * @param {object} context - Context passed to each handler
84
+ * @returns {Promise<{ results: Array<{ name: string, result: any, error?: string }>, executed: number }>}
85
+ */
86
+ export async function executeExtensions(point, context = {}) {
87
+ const extensions = getExtensions(point);
88
+ const results = [];
89
+
90
+ for (const ext of extensions) {
91
+ try {
92
+ const result = await ext.handler(context);
93
+ results.push({ name: ext.name, result });
94
+ } catch (err) {
95
+ results.push({ name: ext.name, result: null, error: err.message });
96
+ }
97
+ }
98
+
99
+ return { results, executed: extensions.length };
100
+ }
101
+
102
+ /**
103
+ * Clear all extensions (or extensions at a specific point).
104
+ *
105
+ * @param {string} [point] - Optional point to clear (clears all if omitted)
106
+ */
107
+ export function clearExtensions(point) {
108
+ if (point) {
109
+ if (_registry.has(point)) {
110
+ _registry.set(point, []);
111
+ }
112
+ } else {
113
+ for (const key of _registry.keys()) {
114
+ _registry.set(key, []);
115
+ }
116
+ }
117
+ }
118
+
119
+ /**
120
+ * Get count of registered extensions.
121
+ *
122
+ * @returns {{ total: number, byPoint: Record<string, number> }}
123
+ */
124
+ export function getExtensionStats() {
125
+ const byPoint = {};
126
+ let total = 0;
127
+
128
+ for (const [point, extensions] of _registry.entries()) {
129
+ byPoint[point] = extensions.length;
130
+ total += extensions.length;
131
+ }
132
+
133
+ return { total, byPoint };
134
+ }
@@ -10,6 +10,8 @@
10
10
  * HALF_OPEN — After reset timeout, allows a single test request
11
11
  */
12
12
 
13
+ import { track as telemetryTrack } from '../telemetry/collector.js';
14
+
13
15
  export const CIRCUIT_STATES = {
14
16
  CLOSED: 'CLOSED',
15
17
  OPEN: 'OPEN',
@@ -61,6 +63,31 @@ export class CircuitBreaker {
61
63
  }
62
64
  }
63
65
 
66
+ /**
67
+ * Execute an async function through the circuit breaker.
68
+ * @param {Function} fn - Async function to execute
69
+ * @returns {Promise<*>} Result from fn
70
+ */
71
+ async executeAsync(fn) {
72
+ if (this._state === CIRCUIT_STATES.OPEN) {
73
+ const elapsed = Date.now() - (this._lastFailure || 0);
74
+ if (elapsed >= this.resetTimeout) {
75
+ this._state = CIRCUIT_STATES.HALF_OPEN;
76
+ } else {
77
+ throw new Error('Circuit breaker is OPEN. Request rejected.');
78
+ }
79
+ }
80
+
81
+ try {
82
+ const result = await fn();
83
+ this._onSuccess();
84
+ return result;
85
+ } catch (error) {
86
+ this._onFailure();
87
+ throw error;
88
+ }
89
+ }
90
+
64
91
  /** @private */
65
92
  _onSuccess() {
66
93
  this._successes++;
@@ -83,6 +110,11 @@ export class CircuitBreaker {
83
110
  this._state = CIRCUIT_STATES.OPEN;
84
111
  } else if (this._failures >= this.failureThreshold) {
85
112
  this._state = CIRCUIT_STATES.OPEN;
113
+ telemetryTrack('circuit_breaker_triggered', {
114
+ trigger: 'failure_threshold',
115
+ agent: 'unknown',
116
+ provider: 'unknown',
117
+ });
86
118
  }
87
119
  }
88
120
 
@@ -6,7 +6,7 @@
6
6
  * source files, lint passes, and no security issues are flagged.
7
7
  */
8
8
 
9
- import { existsSync, readdirSync } from 'node:fs';
9
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
10
10
  import { join } from 'node:path';
11
11
  import { GateBase } from './gate-base.js';
12
12
  import { loadSession } from '../orchestrator/session-manager.js';
@@ -75,10 +75,23 @@ export class ImplementationGate extends GateBase {
75
75
  });
76
76
  }
77
77
 
78
- // Check for lint results file
78
+ // Check for lint results file — parse content for real findings
79
79
  const lintResultPath = join(projectDir, '.lint-result');
80
80
  if (existsSync(lintResultPath)) {
81
- evidence.lintPasses = true; // Presence of file assumed to mean lint was run
81
+ try {
82
+ const content = readFileSync(lintResultPath, 'utf-8');
83
+ const hasErrors = /error|failed|failure|\d+\s+error/i.test(content);
84
+ const hasPassIndicator = /pass|clean|ok|no\s*(errors|issues|warnings)|0\s+errors|success/i.test(content);
85
+ if (hasErrors && !hasPassIndicator) {
86
+ evidence.lintPasses = false;
87
+ } else if (hasPassIndicator) {
88
+ evidence.lintPasses = true;
89
+ } else {
90
+ evidence.lintPasses = null; // Ambiguous — file exists but no clear verdict
91
+ }
92
+ } catch {
93
+ evidence.lintPasses = null;
94
+ }
82
95
  }
83
96
 
84
97
  // Check for security scan results
@@ -88,7 +101,20 @@ export class ImplementationGate extends GateBase {
88
101
  ];
89
102
  for (const secPath of securityPaths) {
90
103
  if (existsSync(secPath)) {
91
- evidence.securityClean = true;
104
+ try {
105
+ const content = readFileSync(secPath, 'utf-8');
106
+ const hasBlockers = /blocker|critical|high\s*severity|vulnerability found|failed/i.test(content);
107
+ const hasPassIndicator = /pass|clean|no\s*(issues|vulnerabilities|findings)|score:\s*(9[5-9]|100)/i.test(content);
108
+ if (hasBlockers) {
109
+ evidence.securityClean = false;
110
+ } else if (hasPassIndicator) {
111
+ evidence.securityClean = true;
112
+ } else {
113
+ evidence.securityClean = null;
114
+ }
115
+ } catch {
116
+ evidence.securityClean = null;
117
+ }
92
118
  break;
93
119
  }
94
120
  }