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
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Telemetry event collector.
|
|
3
|
+
*
|
|
4
|
+
* Buffers events in memory during pipeline execution.
|
|
5
|
+
* Events are flushed (sent) at pipeline completion or on demand.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { validateEvent } from './schema.js';
|
|
9
|
+
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
// Collector
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
|
|
14
|
+
/** @type {Array<{ type: string, properties: object, timestamp: string }>} */
|
|
15
|
+
let buffer = [];
|
|
16
|
+
|
|
17
|
+
/** @type {boolean} */
|
|
18
|
+
let enabled = false;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Initialize the collector with enabled state.
|
|
22
|
+
*
|
|
23
|
+
* @param {boolean} isEnabled
|
|
24
|
+
*/
|
|
25
|
+
export function initCollector(isEnabled) {
|
|
26
|
+
enabled = isEnabled;
|
|
27
|
+
buffer = [];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Track a telemetry event.
|
|
32
|
+
* Events are buffered until flush() is called.
|
|
33
|
+
* Invalid events are silently dropped.
|
|
34
|
+
*
|
|
35
|
+
* @param {string} type - Event type (from TELEMETRY_EVENTS)
|
|
36
|
+
* @param {object} [properties={}] - Event properties
|
|
37
|
+
*/
|
|
38
|
+
export function track(type, properties = {}) {
|
|
39
|
+
if (!enabled) return;
|
|
40
|
+
|
|
41
|
+
const event = { type, properties };
|
|
42
|
+
const validation = validateEvent(event);
|
|
43
|
+
|
|
44
|
+
if (!validation.valid) return; // Silently drop invalid events
|
|
45
|
+
|
|
46
|
+
buffer.push({
|
|
47
|
+
type,
|
|
48
|
+
properties,
|
|
49
|
+
timestamp: new Date().toISOString(),
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Flush all buffered events and clear the buffer.
|
|
55
|
+
* Returns empty array if telemetry is disabled.
|
|
56
|
+
*
|
|
57
|
+
* @returns {Array<{ type: string, properties: object, timestamp: string }>}
|
|
58
|
+
*/
|
|
59
|
+
export function flush() {
|
|
60
|
+
if (!enabled) return [];
|
|
61
|
+
|
|
62
|
+
const events = [...buffer];
|
|
63
|
+
buffer = [];
|
|
64
|
+
return events;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Get current buffer size (for diagnostics).
|
|
69
|
+
*
|
|
70
|
+
* @returns {number}
|
|
71
|
+
*/
|
|
72
|
+
export function getBufferSize() {
|
|
73
|
+
return buffer.length;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Get status of the collector.
|
|
78
|
+
*
|
|
79
|
+
* @returns {{ enabled: boolean, buffered: number }}
|
|
80
|
+
*/
|
|
81
|
+
export function getStatus() {
|
|
82
|
+
return { enabled, buffered: buffer.length };
|
|
83
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Telemetry configuration reader/writer.
|
|
3
|
+
*
|
|
4
|
+
* Reads and writes telemetry preferences from chati.dev/config.yaml.
|
|
5
|
+
* Manages the anonymous UUID for tracking.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { readFileSync, writeFileSync, existsSync } from 'fs';
|
|
9
|
+
import { join } from 'path';
|
|
10
|
+
import { randomUUID } from 'crypto';
|
|
11
|
+
import yaml from 'js-yaml';
|
|
12
|
+
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
// Config Management
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Read telemetry config from project's config.yaml.
|
|
19
|
+
*
|
|
20
|
+
* @param {string} targetDir - Project root directory
|
|
21
|
+
* @returns {{ enabled: boolean, anonymousId: string | null, endpoint: string }}
|
|
22
|
+
*/
|
|
23
|
+
export function getTelemetryConfig(targetDir) {
|
|
24
|
+
const configPath = join(targetDir, 'chati.dev', 'config.yaml');
|
|
25
|
+
|
|
26
|
+
const defaults = {
|
|
27
|
+
enabled: false,
|
|
28
|
+
anonymousId: null,
|
|
29
|
+
endpoint: 'https://chati-telemetry.vercel.app/api/events',
|
|
30
|
+
apiKey: '10b0b54ba4f392fa46379ba778062ab0af5ca61e79609a7dce4aadd660104b56',
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
if (!existsSync(configPath)) return defaults;
|
|
34
|
+
|
|
35
|
+
try {
|
|
36
|
+
const content = readFileSync(configPath, 'utf-8');
|
|
37
|
+
const config = yaml.load(content);
|
|
38
|
+
const telemetry = config?.telemetry || {};
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
enabled: telemetry.enabled === true,
|
|
42
|
+
anonymousId: telemetry.anonymous_id || null,
|
|
43
|
+
endpoint: telemetry.endpoint || defaults.endpoint,
|
|
44
|
+
apiKey: telemetry.api_key || defaults.apiKey,
|
|
45
|
+
};
|
|
46
|
+
} catch {
|
|
47
|
+
return defaults;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Check if telemetry is enabled.
|
|
53
|
+
*
|
|
54
|
+
* @param {string} targetDir
|
|
55
|
+
* @returns {boolean}
|
|
56
|
+
*/
|
|
57
|
+
export function isEnabled(targetDir) {
|
|
58
|
+
return getTelemetryConfig(targetDir).enabled;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Set telemetry enabled/disabled in config.yaml.
|
|
63
|
+
* Generates anonymous UUID on first enable.
|
|
64
|
+
*
|
|
65
|
+
* @param {string} targetDir
|
|
66
|
+
* @param {boolean} enabled
|
|
67
|
+
*/
|
|
68
|
+
export function setEnabled(targetDir, enabled) {
|
|
69
|
+
const configPath = join(targetDir, 'chati.dev', 'config.yaml');
|
|
70
|
+
|
|
71
|
+
if (!existsSync(configPath)) return;
|
|
72
|
+
|
|
73
|
+
try {
|
|
74
|
+
const content = readFileSync(configPath, 'utf-8');
|
|
75
|
+
const config = yaml.load(content) || {};
|
|
76
|
+
|
|
77
|
+
if (!config.telemetry) config.telemetry = {};
|
|
78
|
+
config.telemetry.enabled = enabled;
|
|
79
|
+
|
|
80
|
+
// Generate UUID on first enable
|
|
81
|
+
if (enabled && !config.telemetry.anonymous_id) {
|
|
82
|
+
config.telemetry.anonymous_id = randomUUID();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
writeFileSync(configPath, yaml.dump(config, { lineWidth: -1 }), 'utf-8');
|
|
86
|
+
} catch {
|
|
87
|
+
// Silently fail — telemetry config is non-critical
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Get or generate the anonymous tracking ID.
|
|
93
|
+
*
|
|
94
|
+
* @param {string} targetDir
|
|
95
|
+
* @returns {string}
|
|
96
|
+
*/
|
|
97
|
+
export function getAnonymousId(targetDir) {
|
|
98
|
+
const config = getTelemetryConfig(targetDir);
|
|
99
|
+
|
|
100
|
+
if (config.anonymousId) return config.anonymousId;
|
|
101
|
+
|
|
102
|
+
// Generate and persist
|
|
103
|
+
const id = randomUUID();
|
|
104
|
+
const configPath = join(targetDir, 'chati.dev', 'config.yaml');
|
|
105
|
+
|
|
106
|
+
if (existsSync(configPath)) {
|
|
107
|
+
try {
|
|
108
|
+
const content = readFileSync(configPath, 'utf-8');
|
|
109
|
+
const parsed = yaml.load(content) || {};
|
|
110
|
+
if (!parsed.telemetry) parsed.telemetry = {};
|
|
111
|
+
parsed.telemetry.anonymous_id = id;
|
|
112
|
+
writeFileSync(configPath, yaml.dump(parsed, { lineWidth: -1 }), 'utf-8');
|
|
113
|
+
} catch {
|
|
114
|
+
// Return generated ID even if persistence fails
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return id;
|
|
119
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Telemetry public API.
|
|
3
|
+
*
|
|
4
|
+
* Provides track(), flush(), and configuration utilities.
|
|
5
|
+
* All telemetry is opt-in and anonymous.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export { TELEMETRY_EVENTS, validateEvent } from './schema.js';
|
|
9
|
+
export { getTelemetryConfig, isEnabled, setEnabled, getAnonymousId } from './config.js';
|
|
10
|
+
export { initCollector, track, flush, getBufferSize, getStatus } from './collector.js';
|
|
11
|
+
export { sendEvents, DEFAULT_ENDPOINT } from './sender.js';
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Telemetry event schema and validation.
|
|
3
|
+
*
|
|
4
|
+
* Defines the 6 event types collected by opt-in telemetry.
|
|
5
|
+
* Zero PII — only anonymous usage metrics.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
// Event Types
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
|
|
12
|
+
export const TELEMETRY_EVENTS = [
|
|
13
|
+
'installation_completed',
|
|
14
|
+
'agent_completed',
|
|
15
|
+
'gate_evaluated',
|
|
16
|
+
'pipeline_completed',
|
|
17
|
+
'circuit_breaker_triggered',
|
|
18
|
+
'error_occurred',
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
// Property Schemas (allowed fields per event type)
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
const EVENT_PROPERTIES = {
|
|
26
|
+
installation_completed: [
|
|
27
|
+
'providers', 'editors', 'projectType', 'language',
|
|
28
|
+
'primaryProvider', 'installDuration',
|
|
29
|
+
],
|
|
30
|
+
agent_completed: [
|
|
31
|
+
'agent', 'provider', 'model', 'duration', 'score',
|
|
32
|
+
'retryCount', 'pipelineType',
|
|
33
|
+
],
|
|
34
|
+
gate_evaluated: [
|
|
35
|
+
'gate', 'result', 'score', 'blockers',
|
|
36
|
+
],
|
|
37
|
+
pipeline_completed: [
|
|
38
|
+
'pipelineType', 'totalDuration', 'agentsRun', 'finalStatus',
|
|
39
|
+
'abandonedAt', 'totalCost', 'deviationCount',
|
|
40
|
+
],
|
|
41
|
+
circuit_breaker_triggered: [
|
|
42
|
+
'trigger', 'agent', 'provider',
|
|
43
|
+
],
|
|
44
|
+
error_occurred: [
|
|
45
|
+
'errorType', 'agent', 'provider', 'phase',
|
|
46
|
+
],
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
// PII blocklist — fields that MUST NEVER appear in telemetry
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
|
|
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',
|
|
59
|
+
];
|
|
60
|
+
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
// Validation
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Validate a telemetry event.
|
|
67
|
+
*
|
|
68
|
+
* @param {{ type: string, properties?: object }} event
|
|
69
|
+
* @returns {{ valid: boolean, errors: string[] }}
|
|
70
|
+
*/
|
|
71
|
+
export function validateEvent(event) {
|
|
72
|
+
const errors = [];
|
|
73
|
+
|
|
74
|
+
if (!event || typeof event !== 'object') {
|
|
75
|
+
return { valid: false, errors: ['Event must be an object'] };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (!event.type || !TELEMETRY_EVENTS.includes(event.type)) {
|
|
79
|
+
errors.push(`Unknown event type: "${event.type}". Valid: ${TELEMETRY_EVENTS.join(', ')}`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const props = event.properties || {};
|
|
83
|
+
|
|
84
|
+
// Check for PII fields
|
|
85
|
+
for (const key of Object.keys(props)) {
|
|
86
|
+
if (PII_BLOCKLIST.includes(key)) {
|
|
87
|
+
errors.push(`PII field detected: "${key}" — must not be included in telemetry`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Check for PII in values (paths, emails)
|
|
92
|
+
for (const [key, value] of Object.entries(props)) {
|
|
93
|
+
if (typeof value === 'string') {
|
|
94
|
+
if (value.includes('/Users/') || value.includes('/home/') || value.includes('C:\\Users\\')) {
|
|
95
|
+
errors.push(`PII detected in "${key}": value contains filesystem path`);
|
|
96
|
+
}
|
|
97
|
+
if (value.includes('@') && value.includes('.')) {
|
|
98
|
+
errors.push(`PII detected in "${key}": value looks like an email`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return { valid: errors.length === 0, errors };
|
|
104
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Telemetry event sender.
|
|
3
|
+
*
|
|
4
|
+
* Sends batched events to the telemetry endpoint via HTTP POST.
|
|
5
|
+
* Fire-and-forget: never blocks the user workflow, fails silently.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
// Default Endpoint
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
|
|
12
|
+
export const DEFAULT_ENDPOINT = 'https://chati-telemetry.vercel.app/api/events';
|
|
13
|
+
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
// Sender
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Send telemetry events to the remote endpoint.
|
|
20
|
+
*
|
|
21
|
+
* Fire-and-forget with 5s timeout. Never throws, never blocks.
|
|
22
|
+
*
|
|
23
|
+
* @param {Array<{ type: string, properties: object, timestamp: string }>} events
|
|
24
|
+
* @param {{ anonymousId: string, version: string, endpoint?: string }} config
|
|
25
|
+
* @returns {Promise<boolean>} true if sent successfully, false otherwise
|
|
26
|
+
*/
|
|
27
|
+
export async function sendEvents(events, config) {
|
|
28
|
+
if (!config || !config.anonymousId || events.length === 0) return false;
|
|
29
|
+
|
|
30
|
+
const endpoint = config.endpoint || DEFAULT_ENDPOINT;
|
|
31
|
+
|
|
32
|
+
const payload = {
|
|
33
|
+
anonymousId: config.anonymousId,
|
|
34
|
+
chatiVersion: config.version || 'unknown',
|
|
35
|
+
nodeVersion: process.version,
|
|
36
|
+
os: process.platform,
|
|
37
|
+
events,
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
try {
|
|
41
|
+
const controller = new AbortController();
|
|
42
|
+
const timeout = setTimeout(() => controller.abort(), 5000);
|
|
43
|
+
|
|
44
|
+
await fetch(endpoint, {
|
|
45
|
+
method: 'POST',
|
|
46
|
+
headers: {
|
|
47
|
+
'Content-Type': 'application/json',
|
|
48
|
+
...(config.apiKey ? { 'X-Telemetry-Key': config.apiKey } : {}),
|
|
49
|
+
},
|
|
50
|
+
body: JSON.stringify(payload),
|
|
51
|
+
signal: controller.signal,
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
clearTimeout(timeout);
|
|
55
|
+
return true;
|
|
56
|
+
} catch {
|
|
57
|
+
// Silently fail — never block user workflow
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -113,7 +113,13 @@ export function getAllProviders() {
|
|
|
113
113
|
*/
|
|
114
114
|
export function loadEnabledProviders(projectDir) {
|
|
115
115
|
const { primary, enabled } = parseProviderConfig(projectDir);
|
|
116
|
-
|
|
116
|
+
const validNames = Object.keys(PROVIDERS);
|
|
117
|
+
|
|
118
|
+
// Filter out invalid provider names (typos in config.yaml)
|
|
119
|
+
const validEnabled = enabled.filter(name => validNames.includes(name));
|
|
120
|
+
const validPrimary = validNames.includes(primary) ? primary : 'claude';
|
|
121
|
+
|
|
122
|
+
return { primary: validPrimary, enabled: validEnabled };
|
|
117
123
|
}
|
|
118
124
|
|
|
119
125
|
/**
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Token cost tracking for terminal spawner.
|
|
3
|
+
*
|
|
4
|
+
* Estimates token usage and cost per agent execution,
|
|
5
|
+
* providing session-level and per-agent cost visibility.
|
|
6
|
+
*
|
|
7
|
+
* Constitution Article XVI — Model Governance.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
// Cost Tables
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Estimated cost per 1K tokens (input + output average) in USD.
|
|
16
|
+
* These are approximations for cost awareness — not billing.
|
|
17
|
+
*/
|
|
18
|
+
export const COST_PER_1K = {
|
|
19
|
+
// Claude models
|
|
20
|
+
opus: 0.075,
|
|
21
|
+
sonnet: 0.015,
|
|
22
|
+
haiku: 0.005,
|
|
23
|
+
// Gemini models
|
|
24
|
+
pro: 0.007,
|
|
25
|
+
flash: 0.001,
|
|
26
|
+
// Codex/Copilot
|
|
27
|
+
codex: 0.010,
|
|
28
|
+
copilot: 0.010,
|
|
29
|
+
mini: 0.003,
|
|
30
|
+
// Fallback
|
|
31
|
+
unknown: 0.015,
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
// Token Estimation
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Estimate token count from text using a simple heuristic.
|
|
40
|
+
* Approximation: ~4 characters per token for English text.
|
|
41
|
+
*
|
|
42
|
+
* @param {string} text
|
|
43
|
+
* @returns {number} Estimated token count
|
|
44
|
+
*/
|
|
45
|
+
export function estimateTokens(text) {
|
|
46
|
+
if (!text || typeof text !== 'string') return 0;
|
|
47
|
+
return Math.ceil(text.length / 4);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
// Cost Tracker
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* @typedef {object} ExecutionRecord
|
|
56
|
+
* @property {string} agent - Agent name
|
|
57
|
+
* @property {string} model - Model tier used
|
|
58
|
+
* @property {string} taskId - Task identifier
|
|
59
|
+
* @property {number} inputTokens - Estimated input tokens
|
|
60
|
+
* @property {number} outputTokens - Estimated output tokens
|
|
61
|
+
* @property {number} cost - Estimated cost in USD
|
|
62
|
+
* @property {number} duration - Execution time in ms
|
|
63
|
+
* @property {string} timestamp - ISO timestamp
|
|
64
|
+
*/
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* @typedef {object} CostReport
|
|
68
|
+
* @property {number} totalCost - Total estimated cost
|
|
69
|
+
* @property {number} totalTokens - Total estimated tokens
|
|
70
|
+
* @property {number} executionCount - Number of executions
|
|
71
|
+
* @property {Record<string, { cost: number, tokens: number, count: number }>} byAgent - Per-agent breakdown
|
|
72
|
+
* @property {Record<string, { cost: number, tokens: number, count: number }>} byModel - Per-model breakdown
|
|
73
|
+
* @property {string} generatedAt - ISO timestamp
|
|
74
|
+
*/
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Create a cost tracker instance.
|
|
78
|
+
*
|
|
79
|
+
* @returns {{ recordExecution: Function, getSessionCost: Function, getAgentCost: Function, exportReport: Function, reset: Function }}
|
|
80
|
+
*/
|
|
81
|
+
export function createCostTracker() {
|
|
82
|
+
/** @type {ExecutionRecord[]} */
|
|
83
|
+
let records = [];
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Record a completed execution.
|
|
87
|
+
*
|
|
88
|
+
* @param {{ agent: string, model?: string, taskId: string, inputText?: string, outputText?: string, duration?: number }} execution
|
|
89
|
+
* @returns {ExecutionRecord}
|
|
90
|
+
*/
|
|
91
|
+
function recordExecution(execution) {
|
|
92
|
+
const model = execution.model || 'unknown';
|
|
93
|
+
const inputTokens = estimateTokens(execution.inputText);
|
|
94
|
+
const outputTokens = estimateTokens(execution.outputText);
|
|
95
|
+
const totalTokens = inputTokens + outputTokens;
|
|
96
|
+
const costRate = COST_PER_1K[model] || COST_PER_1K.unknown;
|
|
97
|
+
const cost = (totalTokens / 1000) * costRate;
|
|
98
|
+
|
|
99
|
+
const record = {
|
|
100
|
+
agent: execution.agent,
|
|
101
|
+
model,
|
|
102
|
+
provider: execution.provider || 'unknown',
|
|
103
|
+
taskId: execution.taskId,
|
|
104
|
+
inputTokens,
|
|
105
|
+
outputTokens,
|
|
106
|
+
cost: Math.round(cost * 1_000_000) / 1_000_000, // 6 decimal places
|
|
107
|
+
duration: execution.duration || 0,
|
|
108
|
+
timestamp: new Date().toISOString(),
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
records.push(record);
|
|
112
|
+
return record;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Get total session cost.
|
|
117
|
+
*
|
|
118
|
+
* @returns {number} Total cost in USD
|
|
119
|
+
*/
|
|
120
|
+
function getSessionCost() {
|
|
121
|
+
return records.reduce((sum, r) => sum + r.cost, 0);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Get cost for a specific agent.
|
|
126
|
+
*
|
|
127
|
+
* @param {string} agent
|
|
128
|
+
* @returns {{ cost: number, tokens: number, count: number }}
|
|
129
|
+
*/
|
|
130
|
+
function getAgentCost(agent) {
|
|
131
|
+
const agentRecords = records.filter(r => r.agent === agent);
|
|
132
|
+
return {
|
|
133
|
+
cost: agentRecords.reduce((sum, r) => sum + r.cost, 0),
|
|
134
|
+
tokens: agentRecords.reduce((sum, r) => sum + r.inputTokens + r.outputTokens, 0),
|
|
135
|
+
count: agentRecords.length,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Export a full cost report.
|
|
141
|
+
*
|
|
142
|
+
* @returns {CostReport}
|
|
143
|
+
*/
|
|
144
|
+
function exportReport() {
|
|
145
|
+
const byAgent = {};
|
|
146
|
+
const byModel = {};
|
|
147
|
+
const byProvider = {};
|
|
148
|
+
|
|
149
|
+
for (const record of records) {
|
|
150
|
+
const totalTokens = record.inputTokens + record.outputTokens;
|
|
151
|
+
|
|
152
|
+
// Aggregate by agent
|
|
153
|
+
if (!byAgent[record.agent]) {
|
|
154
|
+
byAgent[record.agent] = { cost: 0, tokens: 0, count: 0 };
|
|
155
|
+
}
|
|
156
|
+
byAgent[record.agent].cost += record.cost;
|
|
157
|
+
byAgent[record.agent].tokens += totalTokens;
|
|
158
|
+
byAgent[record.agent].count += 1;
|
|
159
|
+
|
|
160
|
+
// Aggregate by model
|
|
161
|
+
if (!byModel[record.model]) {
|
|
162
|
+
byModel[record.model] = { cost: 0, tokens: 0, count: 0 };
|
|
163
|
+
}
|
|
164
|
+
byModel[record.model].cost += record.cost;
|
|
165
|
+
byModel[record.model].tokens += totalTokens;
|
|
166
|
+
byModel[record.model].count += 1;
|
|
167
|
+
|
|
168
|
+
// Aggregate by provider
|
|
169
|
+
const prov = record.provider || 'unknown';
|
|
170
|
+
if (!byProvider[prov]) {
|
|
171
|
+
byProvider[prov] = { cost: 0, tokens: 0, count: 0 };
|
|
172
|
+
}
|
|
173
|
+
byProvider[prov].cost += record.cost;
|
|
174
|
+
byProvider[prov].tokens += totalTokens;
|
|
175
|
+
byProvider[prov].count += 1;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return {
|
|
179
|
+
totalCost: getSessionCost(),
|
|
180
|
+
totalTokens: records.reduce((sum, r) => sum + r.inputTokens + r.outputTokens, 0),
|
|
181
|
+
executionCount: records.length,
|
|
182
|
+
byAgent,
|
|
183
|
+
byModel,
|
|
184
|
+
byProvider,
|
|
185
|
+
generatedAt: new Date().toISOString(),
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Reset all tracked records.
|
|
191
|
+
*/
|
|
192
|
+
function reset() {
|
|
193
|
+
records = [];
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return { recordExecution, getSessionCost, getAgentCost, exportReport, reset };
|
|
197
|
+
}
|
|
@@ -4,28 +4,79 @@
|
|
|
4
4
|
* Agents running in separate `claude -p` terminals include a
|
|
5
5
|
* <chati-handoff> block in their output. This module extracts
|
|
6
6
|
* and parses that block so the orchestrator can read the results.
|
|
7
|
+
*
|
|
8
|
+
* Includes integrity validation via schema checking (Item 13).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { validateSchema, HANDOFF_SCHEMA } from '../utils/schema-validator.js';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Valid status values for handoff blocks.
|
|
15
|
+
* @type {string[]}
|
|
7
16
|
*/
|
|
17
|
+
const VALID_STATUSES = ['APPROVED', 'NEEDS_REVISION', 'BLOCKED', 'unknown'];
|
|
8
18
|
|
|
9
19
|
/**
|
|
10
20
|
* Parse the <chati-handoff> block from agent stdout.
|
|
11
21
|
*
|
|
22
|
+
* Returns validation info alongside parsed data for integrity checking.
|
|
23
|
+
*
|
|
12
24
|
* @param {string} output - Full stdout from the agent process
|
|
13
|
-
* @returns {{ found: boolean, handoff: object|null, rawOutput: string }}
|
|
25
|
+
* @returns {{ found: boolean, handoff: object|null, rawOutput: string, valid: boolean, warnings: string[] }}
|
|
14
26
|
*/
|
|
15
27
|
export function parseAgentOutput(output) {
|
|
16
28
|
if (!output || typeof output !== 'string') {
|
|
17
|
-
return { found: false, handoff: null, rawOutput: '' };
|
|
29
|
+
return { found: false, handoff: null, rawOutput: '', valid: false, warnings: ['No output provided'] };
|
|
18
30
|
}
|
|
19
31
|
|
|
20
32
|
const match = output.match(/<chati-handoff>([\s\S]*?)<\/chati-handoff>/);
|
|
21
33
|
if (!match) {
|
|
22
|
-
return { found: false, handoff: null, rawOutput: output };
|
|
34
|
+
return { found: false, handoff: null, rawOutput: output, valid: false, warnings: ['No handoff block found'] };
|
|
23
35
|
}
|
|
24
36
|
|
|
25
37
|
const content = match[1].trim();
|
|
26
38
|
const handoff = parseHandoffFields(content);
|
|
27
39
|
|
|
28
|
-
|
|
40
|
+
// Validate the parsed handoff
|
|
41
|
+
const { valid, warnings } = validateHandoff(handoff);
|
|
42
|
+
|
|
43
|
+
return { found: true, handoff, rawOutput: output, valid, warnings };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Validate a parsed handoff against the schema and business rules.
|
|
48
|
+
*
|
|
49
|
+
* @param {object} handoff
|
|
50
|
+
* @returns {{ valid: boolean, warnings: string[] }}
|
|
51
|
+
*/
|
|
52
|
+
export function validateHandoff(handoff) {
|
|
53
|
+
const warnings = [];
|
|
54
|
+
|
|
55
|
+
if (!handoff) {
|
|
56
|
+
return { valid: false, warnings: ['Handoff is null'] };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Schema validation
|
|
60
|
+
const schemaResult = validateSchema(handoff, HANDOFF_SCHEMA);
|
|
61
|
+
warnings.push(...schemaResult.errors, ...schemaResult.warnings);
|
|
62
|
+
|
|
63
|
+
// Business rule: status must be a valid value
|
|
64
|
+
if (handoff.status && !VALID_STATUSES.includes(handoff.status)) {
|
|
65
|
+
warnings.push(`Invalid status "${handoff.status}" — expected one of: ${VALID_STATUSES.join(', ')}`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Business rule: score must be 0-100
|
|
69
|
+
if (handoff.score !== null && handoff.score !== undefined) {
|
|
70
|
+
if (typeof handoff.score !== 'number' || handoff.score < 0 || handoff.score > 100) {
|
|
71
|
+
warnings.push(`Score ${handoff.score} is out of range 0-100`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// A handoff is valid if there are no errors (warnings are OK)
|
|
76
|
+
const hasErrors = schemaResult.errors.length > 0 ||
|
|
77
|
+
(handoff.status && !VALID_STATUSES.includes(handoff.status));
|
|
78
|
+
|
|
79
|
+
return { valid: !hasErrors, warnings };
|
|
29
80
|
}
|
|
30
81
|
|
|
31
82
|
/**
|
|
@@ -52,6 +103,8 @@ function parseHandoffFields(content) {
|
|
|
52
103
|
decisions: {},
|
|
53
104
|
blockers: [],
|
|
54
105
|
needs_input_question: null,
|
|
106
|
+
provider: null,
|
|
107
|
+
model: null,
|
|
55
108
|
};
|
|
56
109
|
|
|
57
110
|
const lines = content.split('\n');
|
|
@@ -119,6 +172,10 @@ function parseHandoffFields(content) {
|
|
|
119
172
|
result.summary = value || '';
|
|
120
173
|
} else if (key === 'needs_input_question') {
|
|
121
174
|
result.needs_input_question = value === 'null' || value === '' ? null : value;
|
|
175
|
+
} else if (key === 'provider') {
|
|
176
|
+
result.provider = value || null;
|
|
177
|
+
} else if (key === 'model') {
|
|
178
|
+
result.model = value || null;
|
|
122
179
|
}
|
|
123
180
|
}
|
|
124
181
|
}
|