chati-dev 3.2.4 → 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.
Files changed (99) hide show
  1. package/LICENSE +96 -0
  2. package/README.md +2 -2
  3. package/bin/chati.js +46 -0
  4. package/framework/agents/build/dev.md +122 -1
  5. package/framework/agents/deploy/devops.md +128 -3
  6. package/framework/agents/discover/brief.md +77 -15
  7. package/framework/agents/discover/brownfield-wu.md +2 -2
  8. package/framework/agents/discover/greenfield-wu.md +3 -3
  9. package/framework/agents/plan/architect.md +2 -2
  10. package/framework/agents/plan/detail.md +3 -3
  11. package/framework/agents/plan/phases.md +127 -2
  12. package/framework/agents/plan/tasks.md +127 -2
  13. package/framework/agents/plan/ux.md +269 -22
  14. package/framework/agents/quality/qa-implementation.md +172 -8
  15. package/framework/agents/quality/qa-planning.md +147 -2
  16. package/framework/config.yaml +9 -5
  17. package/framework/constitution.md +7 -1
  18. package/framework/context/quality.md +1 -1
  19. package/framework/context/root.md +1 -1
  20. package/framework/hooks/constitution-guard.js +18 -2
  21. package/framework/hooks/mode-governance.js +3 -3
  22. package/framework/hooks/read-protection.js +10 -2
  23. package/framework/i18n/en.yaml +6 -0
  24. package/framework/i18n/es.yaml +6 -0
  25. package/framework/i18n/fr.yaml +6 -0
  26. package/framework/i18n/pt.yaml +6 -0
  27. package/framework/orchestrator/chati.md +102 -6
  28. package/framework/schemas/task.schema.json +1 -1
  29. package/framework/tasks/architect-dep-audit.md +128 -0
  30. package/framework/tasks/architect-stack-selection.md +28 -0
  31. package/framework/workflows/brownfield-fullstack.yaml +2 -2
  32. package/framework/workflows/brownfield-service.yaml +2 -2
  33. package/framework/workflows/brownfield-ui.yaml +2 -2
  34. package/framework/workflows/greenfield-fullstack.yaml +6 -2
  35. package/framework/workflows/quick-flow.yaml +7 -5
  36. package/framework/workflows/standard-flow.yaml +171 -0
  37. package/package.json +5 -3
  38. package/src/api/index.js +129 -0
  39. package/src/autonomy/build-loop.js +93 -6
  40. package/src/autonomy/build-state.js +20 -2
  41. package/src/autonomy/cause-analyzer.js +177 -0
  42. package/src/autonomy/escalation.js +214 -0
  43. package/src/autonomy/safety-net.js +23 -5
  44. package/src/autonomy/worktree-manager.js +245 -0
  45. package/src/config/agent-customizer.js +227 -0
  46. package/src/config/ide-configs.js +57 -27
  47. package/src/decision/analyzer.js +148 -0
  48. package/src/decision/registry-healer.js +38 -21
  49. package/src/extensions/loader.js +151 -0
  50. package/src/extensions/registry.js +134 -0
  51. package/src/gates/circuit-breaker.js +32 -0
  52. package/src/gates/g3-implementation.js +30 -4
  53. package/src/gates/g4-qa-implementation.js +34 -5
  54. package/src/gates/gate-base.js +9 -0
  55. package/src/health/auto-fix.js +216 -0
  56. package/src/installer/core.js +24 -11
  57. package/src/installer/provider-overlay.js +82 -0
  58. package/src/installer/templates.js +22 -10
  59. package/src/installer/transaction.js +3 -2
  60. package/src/installer/validator.js +74 -0
  61. package/src/intelligence/context-status.js +9 -5
  62. package/src/intelligence/document-sharder.js +221 -0
  63. package/src/intelligence/elicitation.js +265 -0
  64. package/src/intelligence/timeline.js +5 -0
  65. package/src/memory/gotchas.js +78 -2
  66. package/src/merger/semantic-merger.js +292 -0
  67. package/src/orchestrator/agent-selector.js +20 -0
  68. package/src/orchestrator/handoff-engine.js +77 -0
  69. package/src/orchestrator/index.js +0 -8
  70. package/src/orchestrator/intent-classifier.js +182 -0
  71. package/src/orchestrator/pipeline-manager.js +125 -1
  72. package/src/orchestrator/session-manager.js +164 -2
  73. package/src/quality/metrics-collector.js +283 -0
  74. package/src/quality/test-runner.js +368 -0
  75. package/src/telemetry/collector.js +83 -0
  76. package/src/telemetry/config.js +119 -0
  77. package/src/telemetry/index.js +11 -0
  78. package/src/telemetry/schema.js +104 -0
  79. package/src/telemetry/sender.js +60 -0
  80. package/src/terminal/cli-registry.js +7 -1
  81. package/src/terminal/cost-tracker.js +197 -0
  82. package/src/terminal/handoff-parser.js +61 -4
  83. package/src/terminal/prompt-builder.js +56 -18
  84. package/src/terminal/rate-limiter.js +172 -0
  85. package/src/terminal/run-agent.js +39 -0
  86. package/src/terminal/run-parallel.js +22 -1
  87. package/src/terminal/spawner.js +181 -3
  88. package/src/upgrade/migrator.js +2 -2
  89. package/src/utils/event-bus.js +126 -0
  90. package/src/utils/file-lock.js +291 -0
  91. package/src/utils/schema-validator.js +226 -0
  92. package/src/wizard/i18n.js +11 -0
  93. package/src/wizard/index.js +42 -20
  94. package/src/wizard/questions.js +200 -39
  95. package/src/autonomy/execution-profile.js +0 -151
  96. package/src/intelligence/file-tracker.js +0 -117
  97. package/src/memory/gotchas-auto-capture.js +0 -253
  98. package/src/orchestrator/pipeline-state.js +0 -223
  99. 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
- return { primary, enabled };
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
- return { found: true, handoff, rawOutput: output };
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
  }