chati-dev 3.3.2 → 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -4
- package/framework/config.yaml +18 -3
- package/framework/constitution.md +12 -10
- package/framework/context/governance.md +2 -0
- package/framework/context/root.md +1 -1
- package/framework/intelligence/context-engine.md +22 -17
- package/package.json +1 -1
- package/scripts/doctor/checks/agents.js +77 -0
- package/scripts/doctor/checks/constitution.js +41 -0
- package/scripts/doctor/checks/domain-alignment.js +58 -0
- package/scripts/doctor/checks/prism-layers.js +84 -0
- package/scripts/doctor/checks/registry.js +55 -0
- package/scripts/doctor/checks/schemas.js +61 -0
- package/scripts/doctor/fixes/reference-fix.js +100 -0
- package/scripts/doctor/fixes/registry-fix.js +56 -0
- package/scripts/doctor/index.js +212 -0
- package/scripts/health-check.js +8 -8
- package/src/autonomy/surface-criteria.js +226 -0
- package/src/context/bracket-tracker.js +44 -13
- package/src/context/domain-loader.js +22 -0
- package/src/context/engine.js +18 -7
- package/src/context/formatter.js +21 -1
- package/src/context/layers/l5-keywords.js +53 -0
- package/src/intelligence/context-status.js +20 -9
- package/src/intelligence/decision-engine.js +253 -0
- package/src/terminal/prompt-builder.js +341 -1
- package/src/terminal/run-agent.js +15 -0
- package/src/terminal/run-parallel.js +77 -5
- package/src/terminal/spawner.js +13 -0
- package/src/utils/feature-flags.js +106 -0
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Doctor fix: Broken path references.
|
|
3
|
+
*
|
|
4
|
+
* Scans entity-registry.yaml for broken path references and
|
|
5
|
+
* attempts to locate the correct paths.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { readFileSync, writeFileSync, existsSync, readdirSync } from 'fs';
|
|
9
|
+
import { join, basename } from 'path';
|
|
10
|
+
import yaml from 'js-yaml';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Fix broken path references in entity-registry.yaml.
|
|
14
|
+
*
|
|
15
|
+
* @param {string} frameworkDir - Path to framework directory
|
|
16
|
+
* @returns {{ fixed: boolean, details: string, fixedPaths: string[] }}
|
|
17
|
+
*/
|
|
18
|
+
export function fixBrokenReferences(frameworkDir) {
|
|
19
|
+
const regPath = join(frameworkDir, 'data', 'entity-registry.yaml');
|
|
20
|
+
if (!existsSync(regPath)) {
|
|
21
|
+
return { fixed: false, details: 'entity-registry.yaml not found', fixedPaths: [] };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
try {
|
|
25
|
+
const content = readFileSync(regPath, 'utf8');
|
|
26
|
+
const registry = yaml.load(content);
|
|
27
|
+
|
|
28
|
+
if (!registry || !registry.entities) {
|
|
29
|
+
return { fixed: false, details: 'No entities section found', fixedPaths: [] };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const fixedPaths = [];
|
|
33
|
+
let updatedContent = content;
|
|
34
|
+
|
|
35
|
+
for (const category of Object.values(registry.entities)) {
|
|
36
|
+
for (const [name, entity] of Object.entries(category)) {
|
|
37
|
+
if (!entity.path) continue;
|
|
38
|
+
|
|
39
|
+
// Path in registry is relative to project root (e.g., "chati.dev/agents/...")
|
|
40
|
+
const relPath = entity.path.replace(/^chati\.dev\//, '');
|
|
41
|
+
const fullPath = join(frameworkDir, relPath);
|
|
42
|
+
|
|
43
|
+
if (existsSync(fullPath)) continue;
|
|
44
|
+
|
|
45
|
+
// Try to find the file by name
|
|
46
|
+
const fileName = basename(relPath);
|
|
47
|
+
const found = findFile(frameworkDir, fileName);
|
|
48
|
+
|
|
49
|
+
if (found) {
|
|
50
|
+
const newPath = 'chati.dev/' + found;
|
|
51
|
+
updatedContent = updatedContent.replace(
|
|
52
|
+
`path: ${entity.path}`,
|
|
53
|
+
`path: ${newPath}`
|
|
54
|
+
);
|
|
55
|
+
fixedPaths.push(`${name}: ${entity.path} -> ${newPath}`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (fixedPaths.length === 0) {
|
|
61
|
+
return { fixed: false, details: 'No broken references found', fixedPaths: [] };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
writeFileSync(regPath, updatedContent, 'utf8');
|
|
65
|
+
return { fixed: true, details: `Fixed ${fixedPaths.length} path reference(s)`, fixedPaths };
|
|
66
|
+
} catch (err) {
|
|
67
|
+
return { fixed: false, details: `Fix failed: ${err.message}`, fixedPaths: [] };
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Search for a file by name within the framework directory.
|
|
73
|
+
*
|
|
74
|
+
* @param {string} dir - Directory to search
|
|
75
|
+
* @param {string} fileName - File name to find
|
|
76
|
+
* @param {string} [prefix=''] - Path prefix for recursion
|
|
77
|
+
* @param {number} [depth=0] - Current depth
|
|
78
|
+
* @returns {string|null} Relative path from frameworkDir or null
|
|
79
|
+
*/
|
|
80
|
+
function findFile(dir, fileName, prefix = '', depth = 0) {
|
|
81
|
+
if (depth > 4) return null;
|
|
82
|
+
|
|
83
|
+
try {
|
|
84
|
+
const entries = readdirSync(dir, { withFileTypes: true });
|
|
85
|
+
for (const entry of entries) {
|
|
86
|
+
const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
87
|
+
if (entry.isFile() && entry.name === fileName) {
|
|
88
|
+
return rel;
|
|
89
|
+
}
|
|
90
|
+
if (entry.isDirectory() && !entry.name.startsWith('.') && entry.name !== 'node_modules') {
|
|
91
|
+
const found = findFile(join(dir, entry.name), fileName, rel, depth + 1);
|
|
92
|
+
if (found) return found;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
} catch {
|
|
96
|
+
// Permission error or inaccessible directory
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Doctor fix: Registry count correction.
|
|
3
|
+
*
|
|
4
|
+
* Auto-fixes entity_count mismatch in entity-registry.yaml
|
|
5
|
+
* by counting actual entities and updating metadata.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { readFileSync, writeFileSync, existsSync } from 'fs';
|
|
9
|
+
import { join } from 'path';
|
|
10
|
+
import yaml from 'js-yaml';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Fix entity count mismatch in entity-registry.yaml.
|
|
14
|
+
*
|
|
15
|
+
* @param {string} frameworkDir - Path to framework directory
|
|
16
|
+
* @returns {{ fixed: boolean, details: string }}
|
|
17
|
+
*/
|
|
18
|
+
export function fixRegistryCount(frameworkDir) {
|
|
19
|
+
const regPath = join(frameworkDir, 'data', 'entity-registry.yaml');
|
|
20
|
+
if (!existsSync(regPath)) {
|
|
21
|
+
return { fixed: false, details: 'entity-registry.yaml not found' };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
try {
|
|
25
|
+
const content = readFileSync(regPath, 'utf8');
|
|
26
|
+
const registry = yaml.load(content);
|
|
27
|
+
|
|
28
|
+
if (!registry || !registry.entities) {
|
|
29
|
+
return { fixed: false, details: 'No entities section found' };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Count actual entities
|
|
33
|
+
let entityCount = 0;
|
|
34
|
+
for (const category of Object.values(registry.entities)) {
|
|
35
|
+
if (typeof category === 'object' && category !== null) {
|
|
36
|
+
entityCount += Object.keys(category).length;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const declaredCount = registry.metadata?.entity_count || 0;
|
|
41
|
+
if (entityCount === declaredCount) {
|
|
42
|
+
return { fixed: false, details: 'Entity count already correct' };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Update the count using regex to preserve YAML formatting
|
|
46
|
+
const updatedContent = content.replace(
|
|
47
|
+
/entity_count:\s*\d+/,
|
|
48
|
+
`entity_count: ${entityCount}`
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
writeFileSync(regPath, updatedContent, 'utf8');
|
|
52
|
+
return { fixed: true, details: `Updated entity_count from ${declaredCount} to ${entityCount}` };
|
|
53
|
+
} catch (err) {
|
|
54
|
+
return { fixed: false, details: `Fix failed: ${err.message}` };
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @fileoverview Doctor — Modular diagnostic system with auto-fix capability.
|
|
5
|
+
*
|
|
6
|
+
* Orchestrates individual check modules and fix modules to provide
|
|
7
|
+
* comprehensive framework health validation and automatic repair.
|
|
8
|
+
*
|
|
9
|
+
* Exports:
|
|
10
|
+
* runDoctor(frameworkDir, options) → DoctorReport
|
|
11
|
+
* formatDoctorReport(report) → human-readable string
|
|
12
|
+
*
|
|
13
|
+
* DoctorReport:
|
|
14
|
+
* { overall, checks, fixes, timestamp, summary }
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { existsSync } from 'fs';
|
|
18
|
+
import { join } from 'path';
|
|
19
|
+
import { fileURLToPath } from 'url';
|
|
20
|
+
|
|
21
|
+
// Check modules
|
|
22
|
+
import { checkRegistry } from './checks/registry.js';
|
|
23
|
+
import { checkSchemas } from './checks/schemas.js';
|
|
24
|
+
import { checkConstitution } from './checks/constitution.js';
|
|
25
|
+
import { checkAgents } from './checks/agents.js';
|
|
26
|
+
import { checkPrismLayers } from './checks/prism-layers.js';
|
|
27
|
+
import { checkDomainAlignment } from './checks/domain-alignment.js';
|
|
28
|
+
|
|
29
|
+
// Fix modules
|
|
30
|
+
import { fixRegistryCount } from './fixes/registry-fix.js';
|
|
31
|
+
import { fixBrokenReferences } from './fixes/reference-fix.js';
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Map of fixId to fix function.
|
|
35
|
+
*/
|
|
36
|
+
const FIX_REGISTRY = {
|
|
37
|
+
'registry-count': fixRegistryCount,
|
|
38
|
+
'broken-references': fixBrokenReferences,
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* All available check functions.
|
|
43
|
+
*/
|
|
44
|
+
const CHECK_REGISTRY = {
|
|
45
|
+
registry: checkRegistry,
|
|
46
|
+
schemas: checkSchemas,
|
|
47
|
+
constitution: checkConstitution,
|
|
48
|
+
agents: checkAgents,
|
|
49
|
+
prismLayers: checkPrismLayers,
|
|
50
|
+
domainAlignment: checkDomainAlignment,
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Run the doctor diagnostic suite.
|
|
55
|
+
*
|
|
56
|
+
* @param {string} frameworkDir - Path to the framework directory
|
|
57
|
+
* @param {object} [options={}] - Configuration options
|
|
58
|
+
* @param {boolean} [options.fix=false] - Run auto-fix for fixable issues
|
|
59
|
+
* @param {string[]} [options.checks] - Specific checks to run (default: all)
|
|
60
|
+
* @returns {object} DoctorReport
|
|
61
|
+
*/
|
|
62
|
+
export function runDoctor(frameworkDir, options = {}) {
|
|
63
|
+
const { fix = false, checks: checkFilter } = options;
|
|
64
|
+
|
|
65
|
+
if (!existsSync(frameworkDir)) {
|
|
66
|
+
return {
|
|
67
|
+
overall: 'UNHEALTHY',
|
|
68
|
+
checks: {
|
|
69
|
+
frameworkDir: { pass: false, details: `Directory not found: ${frameworkDir}`, severity: 'critical', fixable: false, fixId: null },
|
|
70
|
+
},
|
|
71
|
+
fixes: [],
|
|
72
|
+
timestamp: new Date().toISOString(),
|
|
73
|
+
summary: { total: 1, passed: 0, failed: 1, criticalFailures: 1, warnings: 0, fixesApplied: 0 },
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Run checks
|
|
78
|
+
const checksToRun = checkFilter
|
|
79
|
+
? Object.entries(CHECK_REGISTRY).filter(([name]) => checkFilter.includes(name))
|
|
80
|
+
: Object.entries(CHECK_REGISTRY);
|
|
81
|
+
|
|
82
|
+
const checkResults = {};
|
|
83
|
+
for (const [name, checkFn] of checksToRun) {
|
|
84
|
+
checkResults[name] = checkFn(frameworkDir);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Run fixes if requested
|
|
88
|
+
const fixes = [];
|
|
89
|
+
if (fix) {
|
|
90
|
+
for (const [name, result] of Object.entries(checkResults)) {
|
|
91
|
+
if (!result.pass && result.fixable && result.fixId) {
|
|
92
|
+
const fixFn = FIX_REGISTRY[result.fixId];
|
|
93
|
+
if (fixFn) {
|
|
94
|
+
const fixResult = fixFn(frameworkDir);
|
|
95
|
+
fixes.push({ check: name, fixId: result.fixId, ...fixResult });
|
|
96
|
+
|
|
97
|
+
// Re-run the check if fix was applied
|
|
98
|
+
if (fixResult.fixed) {
|
|
99
|
+
const checkFn = CHECK_REGISTRY[name];
|
|
100
|
+
if (checkFn) {
|
|
101
|
+
checkResults[name] = checkFn(frameworkDir);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Determine overall health
|
|
110
|
+
const criticalFailures = Object.values(checkResults).filter(c => !c.pass && c.severity === 'critical');
|
|
111
|
+
const warnings = Object.values(checkResults).filter(c => !c.pass && c.severity === 'warning');
|
|
112
|
+
|
|
113
|
+
let overall;
|
|
114
|
+
if (criticalFailures.length > 0) {
|
|
115
|
+
overall = 'UNHEALTHY';
|
|
116
|
+
} else if (warnings.length > 0) {
|
|
117
|
+
overall = 'DEGRADED';
|
|
118
|
+
} else {
|
|
119
|
+
overall = 'HEALTHY';
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return {
|
|
123
|
+
overall,
|
|
124
|
+
checks: checkResults,
|
|
125
|
+
fixes,
|
|
126
|
+
timestamp: new Date().toISOString(),
|
|
127
|
+
summary: {
|
|
128
|
+
total: Object.keys(checkResults).length,
|
|
129
|
+
passed: Object.values(checkResults).filter(c => c.pass).length,
|
|
130
|
+
failed: Object.values(checkResults).filter(c => !c.pass).length,
|
|
131
|
+
criticalFailures: criticalFailures.length,
|
|
132
|
+
warnings: warnings.length,
|
|
133
|
+
fixesApplied: fixes.filter(f => f.fixed).length,
|
|
134
|
+
},
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Format a DoctorReport into a human-readable string.
|
|
140
|
+
*
|
|
141
|
+
* @param {object} report - DoctorReport from runDoctor
|
|
142
|
+
* @returns {string} Formatted report
|
|
143
|
+
*/
|
|
144
|
+
export function formatDoctorReport(report) {
|
|
145
|
+
const statusSymbol = {
|
|
146
|
+
HEALTHY: '[OK]',
|
|
147
|
+
DEGRADED: '[WARN]',
|
|
148
|
+
UNHEALTHY: '[FAIL]',
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
const checkSymbol = (check) => check.pass ? '[PASS]' : '[FAIL]';
|
|
152
|
+
|
|
153
|
+
const lines = [
|
|
154
|
+
'=== Doctor Report ===',
|
|
155
|
+
`Status: ${statusSymbol[report.overall] || '[ ? ]'} ${report.overall}`,
|
|
156
|
+
`Time: ${report.timestamp}`,
|
|
157
|
+
'',
|
|
158
|
+
];
|
|
159
|
+
|
|
160
|
+
if (report.summary) {
|
|
161
|
+
lines.push(`Summary: ${report.summary.passed}/${report.summary.total} checks passed`);
|
|
162
|
+
if (report.summary.criticalFailures > 0) {
|
|
163
|
+
lines.push(` ${report.summary.criticalFailures} critical failure(s)`);
|
|
164
|
+
}
|
|
165
|
+
if (report.summary.warnings > 0) {
|
|
166
|
+
lines.push(` ${report.summary.warnings} warning(s)`);
|
|
167
|
+
}
|
|
168
|
+
if (report.summary.fixesApplied > 0) {
|
|
169
|
+
lines.push(` ${report.summary.fixesApplied} fix(es) applied`);
|
|
170
|
+
}
|
|
171
|
+
lines.push('');
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
lines.push('Checks:');
|
|
175
|
+
for (const [name, check] of Object.entries(report.checks)) {
|
|
176
|
+
const symbol = checkSymbol(check);
|
|
177
|
+
const severity = check.severity !== 'info' ? ` (${check.severity})` : '';
|
|
178
|
+
const fixable = check.fixable ? ' [fixable]' : '';
|
|
179
|
+
lines.push(` ${symbol} ${name}: ${check.details}${severity}${fixable}`);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (report.fixes && report.fixes.length > 0) {
|
|
183
|
+
lines.push('');
|
|
184
|
+
lines.push('Fixes:');
|
|
185
|
+
for (const fix of report.fixes) {
|
|
186
|
+
const symbol = fix.fixed ? '[FIXED]' : '[SKIP]';
|
|
187
|
+
lines.push(` ${symbol} ${fix.check}: ${fix.details}`);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
lines.push('');
|
|
192
|
+
lines.push('=== End Report ===');
|
|
193
|
+
|
|
194
|
+
return lines.join('\n');
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// ---------------------------------------------------------------------------
|
|
198
|
+
// CLI entrypoint
|
|
199
|
+
// ---------------------------------------------------------------------------
|
|
200
|
+
|
|
201
|
+
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
|
202
|
+
const args = process.argv.slice(2);
|
|
203
|
+
const fix = args.includes('--fix');
|
|
204
|
+
const frameworkDir = args.find(a => !a.startsWith('--')) || join(process.cwd(), 'chati.dev');
|
|
205
|
+
|
|
206
|
+
const report = runDoctor(frameworkDir, { fix });
|
|
207
|
+
console.log(formatDoctorReport(report));
|
|
208
|
+
|
|
209
|
+
if (report.overall === 'UNHEALTHY') {
|
|
210
|
+
process.exit(1);
|
|
211
|
+
}
|
|
212
|
+
}
|
package/scripts/health-check.js
CHANGED
|
@@ -146,14 +146,14 @@ function checkAgents(frameworkDir) {
|
|
|
146
146
|
const orchestratorDir = join(frameworkDir, 'orchestrator');
|
|
147
147
|
|
|
148
148
|
const expectedAgents = {
|
|
149
|
-
'
|
|
150
|
-
'
|
|
151
|
-
'
|
|
152
|
-
'
|
|
153
|
-
'
|
|
154
|
-
'
|
|
155
|
-
'
|
|
156
|
-
'
|
|
149
|
+
'discover/greenfield-wu.md': 'greenfield-wu',
|
|
150
|
+
'discover/brownfield-wu.md': 'brownfield-wu',
|
|
151
|
+
'discover/brief.md': 'brief',
|
|
152
|
+
'plan/detail.md': 'detail',
|
|
153
|
+
'plan/architect.md': 'architect',
|
|
154
|
+
'plan/ux.md': 'ux',
|
|
155
|
+
'plan/phases.md': 'phases',
|
|
156
|
+
'plan/tasks.md': 'tasks',
|
|
157
157
|
'quality/qa-planning.md': 'qa-planning',
|
|
158
158
|
'quality/qa-implementation.md': 'qa-implementation',
|
|
159
159
|
'build/dev.md': 'dev',
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Surface Criteria — 7 codified checkpoints for autonomous execution pause.
|
|
3
|
+
*
|
|
4
|
+
* Defines WHEN the system should surface a decision to the user instead
|
|
5
|
+
* of proceeding autonomously. Each criterion has a severity level:
|
|
6
|
+
* critical — always pause, cannot be batch-confirmed
|
|
7
|
+
* warning — pause unless already confirmed, batchable
|
|
8
|
+
*
|
|
9
|
+
* Short-circuit: critical criteria (C003, C006, C007) are checked first.
|
|
10
|
+
* Batch confirm: C001 + C002 can be grouped into a single user prompt.
|
|
11
|
+
* Session memory: once confirmed, a criterion can be skipped for the session.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The 7 surface criteria.
|
|
16
|
+
*/
|
|
17
|
+
export const CRITERIA = {
|
|
18
|
+
C001: {
|
|
19
|
+
id: 'C001',
|
|
20
|
+
name: 'ambiguous_requirement',
|
|
21
|
+
severity: 'warning',
|
|
22
|
+
description: 'Requirement is ambiguous and can be interpreted multiple ways.',
|
|
23
|
+
},
|
|
24
|
+
C002: {
|
|
25
|
+
id: 'C002',
|
|
26
|
+
name: 'multiple_approaches',
|
|
27
|
+
severity: 'warning',
|
|
28
|
+
description: 'Multiple valid implementation approaches exist.',
|
|
29
|
+
},
|
|
30
|
+
C003: {
|
|
31
|
+
id: 'C003',
|
|
32
|
+
name: 'destructive_operation',
|
|
33
|
+
severity: 'critical',
|
|
34
|
+
description: 'Action would delete, overwrite, or irreversibly modify existing data or code.',
|
|
35
|
+
},
|
|
36
|
+
C004: {
|
|
37
|
+
id: 'C004',
|
|
38
|
+
name: 'external_interaction',
|
|
39
|
+
severity: 'warning',
|
|
40
|
+
description: 'Action involves external systems (APIs, deployments, notifications).',
|
|
41
|
+
},
|
|
42
|
+
C005: {
|
|
43
|
+
id: 'C005',
|
|
44
|
+
name: 'cost_threshold',
|
|
45
|
+
severity: 'warning',
|
|
46
|
+
description: 'Estimated cost exceeds defined threshold for the session.',
|
|
47
|
+
},
|
|
48
|
+
C006: {
|
|
49
|
+
id: 'C006',
|
|
50
|
+
name: 'scope_creep',
|
|
51
|
+
severity: 'critical',
|
|
52
|
+
description: 'Proposed changes extend beyond the original scope or task definition.',
|
|
53
|
+
},
|
|
54
|
+
C007: {
|
|
55
|
+
id: 'C007',
|
|
56
|
+
name: 'security_sensitive',
|
|
57
|
+
severity: 'critical',
|
|
58
|
+
description: 'Action involves credentials, secrets, permissions, or security-sensitive operations.',
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Check if a criterion has already been confirmed in this session.
|
|
64
|
+
*
|
|
65
|
+
* @param {string} criterionId - e.g., 'C001'
|
|
66
|
+
* @param {Set<string>|string[]} sessionDecisions - Previously confirmed criteria
|
|
67
|
+
* @returns {boolean}
|
|
68
|
+
*/
|
|
69
|
+
export function isAlreadyConfirmed(criterionId, sessionDecisions) {
|
|
70
|
+
if (!sessionDecisions) return false;
|
|
71
|
+
if (sessionDecisions instanceof Set) return sessionDecisions.has(criterionId);
|
|
72
|
+
if (Array.isArray(sessionDecisions)) return sessionDecisions.includes(criterionId);
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Evaluate surface criteria for a given action context.
|
|
78
|
+
*
|
|
79
|
+
* @param {object} action - The action being evaluated
|
|
80
|
+
* @param {string} [action.type] - Action type (e.g., 'write', 'delete', 'deploy', 'create')
|
|
81
|
+
* @param {string} [action.target] - Target of the action (e.g., file path, resource name)
|
|
82
|
+
* @param {string} [action.description] - Human-readable action description
|
|
83
|
+
* @param {string[]} [action.tags] - Semantic tags (e.g., ['destructive', 'external', 'security'])
|
|
84
|
+
* @param {number} [action.estimatedCost] - Estimated cost in dollars
|
|
85
|
+
* @param {object} context - Evaluation context
|
|
86
|
+
* @param {Set<string>|string[]} [context.sessionDecisions] - Previously confirmed criteria
|
|
87
|
+
* @param {number} [context.costThreshold] - Cost threshold for C005 (default: 1.0)
|
|
88
|
+
* @param {string[]} [context.taskScope] - Original task scope keywords
|
|
89
|
+
* @returns {{ shouldPause: boolean, triggers: Array<{ id: string, name: string, severity: string, reason: string }>, batchable: boolean }}
|
|
90
|
+
*/
|
|
91
|
+
export function evaluateSurfaceCriteria(action, context = {}) {
|
|
92
|
+
if (!action) {
|
|
93
|
+
return { shouldPause: false, triggers: [], batchable: false };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const triggers = [];
|
|
97
|
+
const sessionDecisions = context.sessionDecisions || new Set();
|
|
98
|
+
const tags = new Set((action.tags || []).map(t => t.toLowerCase()));
|
|
99
|
+
const actionType = (action.type || '').toLowerCase();
|
|
100
|
+
|
|
101
|
+
// --- Critical criteria first (short-circuit) ---
|
|
102
|
+
|
|
103
|
+
// C003: Destructive operation
|
|
104
|
+
if (isDestructive(actionType, tags) && !isAlreadyConfirmed('C003', sessionDecisions)) {
|
|
105
|
+
triggers.push({
|
|
106
|
+
id: 'C003',
|
|
107
|
+
name: CRITERIA.C003.name,
|
|
108
|
+
severity: 'critical',
|
|
109
|
+
reason: `Destructive operation: ${action.description || action.type || 'unknown'}`,
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// C006: Scope creep
|
|
114
|
+
if (isScopeCreep(action, context) && !isAlreadyConfirmed('C006', sessionDecisions)) {
|
|
115
|
+
triggers.push({
|
|
116
|
+
id: 'C006',
|
|
117
|
+
name: CRITERIA.C006.name,
|
|
118
|
+
severity: 'critical',
|
|
119
|
+
reason: `Changes extend beyond original task scope.`,
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// C007: Security sensitive
|
|
124
|
+
if (isSecuritySensitive(actionType, tags, action.target) && !isAlreadyConfirmed('C007', sessionDecisions)) {
|
|
125
|
+
triggers.push({
|
|
126
|
+
id: 'C007',
|
|
127
|
+
name: CRITERIA.C007.name,
|
|
128
|
+
severity: 'critical',
|
|
129
|
+
reason: `Security-sensitive operation: ${action.description || action.target || 'unknown'}`,
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// --- Warning criteria ---
|
|
134
|
+
|
|
135
|
+
// C001: Ambiguous requirement
|
|
136
|
+
if (tags.has('ambiguous') && !isAlreadyConfirmed('C001', sessionDecisions)) {
|
|
137
|
+
triggers.push({
|
|
138
|
+
id: 'C001',
|
|
139
|
+
name: CRITERIA.C001.name,
|
|
140
|
+
severity: 'warning',
|
|
141
|
+
reason: 'Requirement is ambiguous.',
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// C002: Multiple approaches
|
|
146
|
+
if (tags.has('multiple_approaches') && !isAlreadyConfirmed('C002', sessionDecisions)) {
|
|
147
|
+
triggers.push({
|
|
148
|
+
id: 'C002',
|
|
149
|
+
name: CRITERIA.C002.name,
|
|
150
|
+
severity: 'warning',
|
|
151
|
+
reason: 'Multiple valid approaches exist.',
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// C004: External interaction
|
|
156
|
+
if (isExternal(actionType, tags) && !isAlreadyConfirmed('C004', sessionDecisions)) {
|
|
157
|
+
triggers.push({
|
|
158
|
+
id: 'C004',
|
|
159
|
+
name: CRITERIA.C004.name,
|
|
160
|
+
severity: 'warning',
|
|
161
|
+
reason: `External interaction: ${action.description || action.type || 'unknown'}`,
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// C005: Cost threshold
|
|
166
|
+
const costThreshold = context.costThreshold ?? 1.0;
|
|
167
|
+
if (action.estimatedCost && action.estimatedCost > costThreshold && !isAlreadyConfirmed('C005', sessionDecisions)) {
|
|
168
|
+
triggers.push({
|
|
169
|
+
id: 'C005',
|
|
170
|
+
name: CRITERIA.C005.name,
|
|
171
|
+
severity: 'warning',
|
|
172
|
+
reason: `Estimated cost $${action.estimatedCost.toFixed(2)} exceeds threshold $${costThreshold.toFixed(2)}.`,
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Determine if batchable (only if ALL triggers are warnings, no critical)
|
|
177
|
+
const hasCritical = triggers.some(t => t.severity === 'critical');
|
|
178
|
+
const warningOnly = triggers.length > 0 && !hasCritical;
|
|
179
|
+
const batchable = warningOnly && triggers.length > 1;
|
|
180
|
+
|
|
181
|
+
return {
|
|
182
|
+
shouldPause: triggers.length > 0,
|
|
183
|
+
triggers,
|
|
184
|
+
batchable,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// ---------------------------------------------------------------------------
|
|
189
|
+
// Internal detection helpers
|
|
190
|
+
// ---------------------------------------------------------------------------
|
|
191
|
+
|
|
192
|
+
function isDestructive(actionType, tags) {
|
|
193
|
+
return (
|
|
194
|
+
tags.has('destructive') ||
|
|
195
|
+
['delete', 'remove', 'drop', 'reset', 'force-push', 'overwrite'].includes(actionType)
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function isSecuritySensitive(actionType, tags, target) {
|
|
200
|
+
if (tags.has('security') || tags.has('credentials') || tags.has('secrets')) return true;
|
|
201
|
+
if (target && /\.(env|pem|key|secret|credentials|password)/i.test(target)) return true;
|
|
202
|
+
if (['chmod', 'chown'].includes(actionType)) return true;
|
|
203
|
+
return false;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function isExternal(actionType, tags) {
|
|
207
|
+
return (
|
|
208
|
+
tags.has('external') ||
|
|
209
|
+
['deploy', 'publish', 'push', 'send', 'notify'].includes(actionType)
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function isScopeCreep(action, context) {
|
|
214
|
+
if (!context.taskScope || context.taskScope.length === 0) return false;
|
|
215
|
+
if (!action.tags || action.tags.length === 0) return false;
|
|
216
|
+
|
|
217
|
+
const scopeSet = new Set(context.taskScope.map(s => s.toLowerCase()));
|
|
218
|
+
const actionTags = action.tags.map(t => t.toLowerCase());
|
|
219
|
+
|
|
220
|
+
// If action has 'scope_creep' tag explicitly
|
|
221
|
+
if (actionTags.includes('scope_creep')) return true;
|
|
222
|
+
|
|
223
|
+
// If none of the action tags overlap with task scope, it may be scope creep
|
|
224
|
+
const hasOverlap = actionTags.some(t => scopeSet.has(t));
|
|
225
|
+
return !hasOverlap && actionTags.length > 0;
|
|
226
|
+
}
|