chati-dev 3.2.5 → 3.3.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/LICENSE +96 -0
- package/bin/chati.js +46 -0
- package/framework/agents/build/dev.md +122 -1
- package/framework/agents/deploy/devops.md +128 -3
- package/framework/agents/discover/brief.md +77 -15
- package/framework/agents/discover/brownfield-wu.md +2 -2
- package/framework/agents/discover/greenfield-wu.md +3 -3
- package/framework/agents/plan/architect.md +2 -2
- package/framework/agents/plan/detail.md +3 -3
- package/framework/agents/plan/phases.md +127 -2
- package/framework/agents/plan/tasks.md +127 -2
- package/framework/agents/plan/ux.md +269 -22
- package/framework/agents/quality/qa-implementation.md +172 -8
- package/framework/agents/quality/qa-planning.md +147 -2
- package/framework/config.yaml +9 -5
- package/framework/constitution.md +7 -1
- package/framework/context/quality.md +1 -1
- package/framework/context/root.md +1 -1
- package/framework/hooks/constitution-guard.js +18 -2
- package/framework/hooks/mode-governance.js +3 -3
- package/framework/hooks/read-protection.js +10 -2
- package/framework/i18n/en.yaml +6 -0
- package/framework/i18n/es.yaml +6 -0
- package/framework/i18n/fr.yaml +6 -0
- package/framework/i18n/pt.yaml +6 -0
- package/framework/orchestrator/chati.md +102 -6
- package/framework/schemas/task.schema.json +1 -1
- package/framework/tasks/architect-dep-audit.md +128 -0
- package/framework/tasks/architect-stack-selection.md +28 -0
- package/framework/workflows/brownfield-fullstack.yaml +2 -2
- package/framework/workflows/brownfield-service.yaml +2 -2
- package/framework/workflows/brownfield-ui.yaml +2 -2
- package/framework/workflows/greenfield-fullstack.yaml +6 -2
- package/framework/workflows/quick-flow.yaml +7 -5
- package/framework/workflows/standard-flow.yaml +171 -0
- package/package.json +4 -2
- package/src/api/index.js +129 -0
- package/src/autonomy/build-loop.js +93 -6
- package/src/autonomy/build-state.js +20 -2
- package/src/autonomy/cause-analyzer.js +177 -0
- package/src/autonomy/escalation.js +214 -0
- package/src/autonomy/safety-net.js +23 -5
- package/src/autonomy/worktree-manager.js +245 -0
- package/src/config/agent-customizer.js +227 -0
- package/src/config/ide-configs.js +57 -27
- package/src/decision/analyzer.js +148 -0
- package/src/decision/registry-healer.js +38 -21
- package/src/extensions/loader.js +151 -0
- package/src/extensions/registry.js +134 -0
- package/src/gates/circuit-breaker.js +32 -0
- package/src/gates/g3-implementation.js +30 -4
- package/src/gates/g4-qa-implementation.js +34 -5
- package/src/gates/gate-base.js +9 -0
- package/src/health/auto-fix.js +216 -0
- package/src/installer/core.js +24 -11
- package/src/installer/provider-overlay.js +82 -0
- package/src/installer/templates.js +22 -10
- package/src/installer/transaction.js +3 -2
- package/src/installer/validator.js +74 -0
- package/src/intelligence/context-status.js +9 -5
- package/src/intelligence/document-sharder.js +221 -0
- package/src/intelligence/elicitation.js +265 -0
- package/src/intelligence/timeline.js +5 -0
- package/src/memory/gotchas.js +78 -2
- package/src/merger/semantic-merger.js +292 -0
- package/src/orchestrator/agent-selector.js +20 -0
- package/src/orchestrator/handoff-engine.js +77 -0
- package/src/orchestrator/index.js +0 -8
- package/src/orchestrator/intent-classifier.js +182 -0
- package/src/orchestrator/pipeline-manager.js +125 -1
- package/src/orchestrator/session-manager.js +164 -2
- package/src/quality/metrics-collector.js +283 -0
- package/src/quality/test-runner.js +368 -0
- package/src/telemetry/collector.js +83 -0
- package/src/telemetry/config.js +119 -0
- package/src/telemetry/index.js +11 -0
- package/src/telemetry/schema.js +104 -0
- package/src/telemetry/sender.js +60 -0
- package/src/terminal/cli-registry.js +7 -1
- package/src/terminal/cost-tracker.js +197 -0
- package/src/terminal/handoff-parser.js +61 -4
- package/src/terminal/prompt-builder.js +56 -18
- package/src/terminal/rate-limiter.js +172 -0
- package/src/terminal/run-agent.js +39 -0
- package/src/terminal/run-parallel.js +22 -1
- package/src/terminal/spawner.js +181 -3
- package/src/upgrade/migrator.js +2 -2
- package/src/utils/event-bus.js +126 -0
- package/src/utils/file-lock.js +291 -0
- package/src/utils/schema-validator.js +226 -0
- package/src/wizard/i18n.js +11 -0
- package/src/wizard/index.js +42 -20
- package/src/wizard/questions.js +200 -39
- package/src/autonomy/execution-profile.js +0 -151
- package/src/intelligence/file-tracker.js +0 -117
- package/src/memory/gotchas-auto-capture.js +0 -253
- package/src/orchestrator/pipeline-state.js +0 -223
- package/src/terminal/wave-analyzer.js +0 -143
package/src/decision/analyzer.js
CHANGED
|
@@ -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
|
|
165
|
-
if (!
|
|
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
|
|
209
|
-
if (!
|
|
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
|
|
249
|
-
if (!
|
|
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
|
|
298
|
-
if (!
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|