chati-dev 3.3.1 → 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.
Files changed (39) hide show
  1. package/README.md +4 -4
  2. package/TERMS_OF_USE.md +56 -0
  3. package/framework/config.yaml +23 -3
  4. package/framework/constitution.md +12 -10
  5. package/framework/context/governance.md +2 -0
  6. package/framework/context/root.md +1 -1
  7. package/framework/intelligence/context-engine.md +22 -17
  8. package/package.json +3 -2
  9. package/scripts/doctor/checks/agents.js +77 -0
  10. package/scripts/doctor/checks/constitution.js +41 -0
  11. package/scripts/doctor/checks/domain-alignment.js +58 -0
  12. package/scripts/doctor/checks/prism-layers.js +84 -0
  13. package/scripts/doctor/checks/registry.js +55 -0
  14. package/scripts/doctor/checks/schemas.js +61 -0
  15. package/scripts/doctor/fixes/reference-fix.js +100 -0
  16. package/scripts/doctor/fixes/registry-fix.js +56 -0
  17. package/scripts/doctor/index.js +212 -0
  18. package/scripts/health-check.js +8 -8
  19. package/src/autonomy/surface-criteria.js +226 -0
  20. package/src/context/bracket-tracker.js +44 -13
  21. package/src/context/domain-loader.js +22 -0
  22. package/src/context/engine.js +18 -7
  23. package/src/context/formatter.js +21 -1
  24. package/src/context/layers/l5-keywords.js +53 -0
  25. package/src/installer/templates.js +1 -1
  26. package/src/intelligence/context-status.js +20 -9
  27. package/src/intelligence/decision-engine.js +253 -0
  28. package/src/orchestrator/pipeline-manager.js +80 -9
  29. package/src/telemetry/config.js +1 -1
  30. package/src/telemetry/index.js +1 -1
  31. package/src/telemetry/schema.js +60 -11
  32. package/src/terminal/prompt-builder.js +341 -1
  33. package/src/terminal/run-agent.js +61 -1
  34. package/src/terminal/run-parallel.js +77 -5
  35. package/src/terminal/spawner.js +13 -0
  36. package/src/utils/feature-flags.js +106 -0
  37. package/src/wizard/i18n.js +8 -3
  38. package/src/wizard/index.js +35 -9
  39. package/src/wizard/questions.js +19 -10
@@ -0,0 +1,61 @@
1
+ /**
2
+ * @fileoverview Doctor check: JSON Schema validation.
3
+ *
4
+ * Validates that all expected schema files exist and contain valid JSON.
5
+ */
6
+
7
+ import { readFileSync, existsSync, readdirSync } from 'fs';
8
+ import { join } from 'path';
9
+
10
+ const EXPECTED_SCHEMAS = [
11
+ 'session.schema.json',
12
+ 'config.schema.json',
13
+ 'context.schema.json',
14
+ 'memory.schema.json',
15
+ 'task.schema.json',
16
+ ];
17
+
18
+ /**
19
+ * Run schema validation check.
20
+ *
21
+ * @param {string} frameworkDir - Path to framework directory
22
+ * @returns {{ pass: boolean, details: string, severity: string, fixable: boolean, fixId: string|null }}
23
+ */
24
+ export function checkSchemas(frameworkDir) {
25
+ const schemasDir = join(frameworkDir, 'schemas');
26
+ if (!existsSync(schemasDir)) {
27
+ return { pass: false, details: 'schemas/ directory not found', severity: 'critical', fixable: false, fixId: null };
28
+ }
29
+
30
+ const missing = [];
31
+ const invalid = [];
32
+
33
+ for (const schema of EXPECTED_SCHEMAS) {
34
+ const schemaPath = join(schemasDir, schema);
35
+ if (!existsSync(schemaPath)) {
36
+ missing.push(schema);
37
+ continue;
38
+ }
39
+ try {
40
+ JSON.parse(readFileSync(schemaPath, 'utf8'));
41
+ } catch {
42
+ invalid.push(schema);
43
+ }
44
+ }
45
+
46
+ if (missing.length > 0 || invalid.length > 0) {
47
+ const issues = [];
48
+ if (missing.length) issues.push(`missing: ${missing.join(', ')}`);
49
+ if (invalid.length) issues.push(`invalid JSON: ${invalid.join(', ')}`);
50
+ return {
51
+ pass: false,
52
+ details: issues.join('; '),
53
+ severity: missing.length > 0 ? 'critical' : 'warning',
54
+ fixable: false,
55
+ fixId: null,
56
+ };
57
+ }
58
+
59
+ const actualSchemas = readdirSync(schemasDir).filter(f => f.endsWith('.json'));
60
+ return { pass: true, details: `${actualSchemas.length} schemas valid`, severity: 'info', fixable: false, fixId: null };
61
+ }
@@ -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
+ }
@@ -146,14 +146,14 @@ function checkAgents(frameworkDir) {
146
146
  const orchestratorDir = join(frameworkDir, 'orchestrator');
147
147
 
148
148
  const expectedAgents = {
149
- 'planning/greenfield-wu.md': 'greenfield-wu',
150
- 'planning/brownfield-wu.md': 'brownfield-wu',
151
- 'planning/brief.md': 'brief',
152
- 'planning/detail.md': 'detail',
153
- 'planning/architect.md': 'architect',
154
- 'planning/ux.md': 'ux',
155
- 'planning/phases.md': 'phases',
156
- 'planning/tasks.md': 'tasks',
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
+ }