chati-dev 4.1.5 → 4.2.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 +6 -4
- package/framework/agents/build/dev.md +104 -3
- package/framework/agents/plan/architect.md +88 -25
- package/framework/agents/plan/detail.md +44 -0
- package/framework/agents/plan/ux-component-engineer.md +94 -25
- package/framework/agents/plan/ux.md +109 -34
- package/framework/agents/quality/qa-implementation.md +207 -6
- package/framework/config.yaml +9 -3
- package/framework/constitution.md +269 -2
- package/framework/context/root.md +1 -1
- package/framework/data/entity-registry.yaml +1 -1
- package/framework/hooks/license-guard.js +13 -6
- package/framework/hooks/prism-engine.js +31 -1
- package/framework/hooks/settings.json +8 -0
- package/framework/hooks/style-guard.js +3 -2
- package/framework/hooks/team-quality-gate.js +145 -0
- package/framework/orchestrator/chati.md +172 -2
- package/framework/schemas/session.schema.json +160 -0
- package/framework/templates/team-build-tasks.yaml +56 -0
- package/framework/templates/team-planning-tasks.yaml +73 -0
- package/package.json +1 -1
- package/src/autonomy/safety-net.js +32 -0
- package/src/config/gemini-hooks-generator.js +5 -5
- package/src/orchestrator/cli.js +412 -8
- package/src/orchestrator/handoff-engine.js +29 -2
- package/src/scanning/index.js +1 -0
- package/src/scanning/ui-scanner.js +120 -0
- package/src/terminal/run-team.js +349 -0
- package/src/terminal/team-task-list.js +226 -0
package/src/orchestrator/cli.js
CHANGED
|
@@ -30,8 +30,11 @@ import {
|
|
|
30
30
|
import { analyzeDeviationImpact, applyDeviation } from './index.js';
|
|
31
31
|
import { detectQuickFlow, detectStandardFlow } from './index.js';
|
|
32
32
|
import { AGENT_FILE_MAP } from '../terminal/prompt-builder.js';
|
|
33
|
-
import { readFileSync, writeFileSync, existsSync } from 'fs';
|
|
33
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
|
|
34
34
|
import { join, resolve } from 'path';
|
|
35
|
+
import {
|
|
36
|
+
initTaskList, readTaskList, getTeamProgress,
|
|
37
|
+
} from '../terminal/team-task-list.js';
|
|
35
38
|
|
|
36
39
|
// ---------------------------------------------------------------------------
|
|
37
40
|
// Constants
|
|
@@ -375,12 +378,27 @@ async function handleNext(projectDir) {
|
|
|
375
378
|
// Resolve model for next agent
|
|
376
379
|
const modelInfo = resolveAgentModel(nextAgent, projectDir);
|
|
377
380
|
|
|
378
|
-
// Check for parallel group
|
|
381
|
+
// Check for parallel group — with Agent Teams override (Article XXI)
|
|
379
382
|
let action, spawnCommand = null, parallelSpawnCommand = null, parallelAgents = [];
|
|
383
|
+
let teamData = null;
|
|
380
384
|
if (nextInfo.isParallel && nextInfo.group.length > 1) {
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
385
|
+
// When agent_teams is enabled, substitute spawn_parallel with spawn_team
|
|
386
|
+
if (isAgentTeamsEnabled(projectDir)) {
|
|
387
|
+
const teamType = nextInfo.group.includes('dev') ? 'build' : 'planning';
|
|
388
|
+
const teamConfig = TEAM_CONFIGS[teamType];
|
|
389
|
+
const teamId = generateTeamId(teamConfig.slug);
|
|
390
|
+
action = 'spawn_team';
|
|
391
|
+
parallelAgents = teamConfig.members;
|
|
392
|
+
teamData = {
|
|
393
|
+
team_id: teamId,
|
|
394
|
+
team_type: teamType,
|
|
395
|
+
members: teamConfig.members,
|
|
396
|
+
};
|
|
397
|
+
} else {
|
|
398
|
+
action = 'spawn_parallel';
|
|
399
|
+
parallelAgents = nextInfo.group;
|
|
400
|
+
parallelSpawnCommand = buildParallelSpawnCommand(nextInfo.group, projectDir, lastAgent, modelInfo.provider, 900000);
|
|
401
|
+
}
|
|
384
402
|
} else if (isInteractive) {
|
|
385
403
|
action = 'activate_interactive';
|
|
386
404
|
} else {
|
|
@@ -391,7 +409,7 @@ async function handleNext(projectDir) {
|
|
|
391
409
|
const progress = getPipelineProgress(pipelineState);
|
|
392
410
|
const bracket = estimateContextBracket(completedAgents.length, AGENT_PIPELINE.length);
|
|
393
411
|
|
|
394
|
-
|
|
412
|
+
const result = {
|
|
395
413
|
action,
|
|
396
414
|
agent: nextAgent,
|
|
397
415
|
agent_file: agentFile,
|
|
@@ -407,6 +425,15 @@ async function handleNext(projectDir) {
|
|
|
407
425
|
session: { language: session.language, project_type: session.project_type || session.project?.type, execution_mode: session.execution_mode, user_level: session.user_level || 'auto' },
|
|
408
426
|
status_summary: `${lastAgent} completed. Next: ${nextAgent} (${action.replace('_', ' ')}, ${agentDef?.phase || 'unknown'} phase).`,
|
|
409
427
|
};
|
|
428
|
+
|
|
429
|
+
// Include team data when spawn_team action is selected (Article XXI)
|
|
430
|
+
if (teamData) {
|
|
431
|
+
result.team_id = teamData.team_id;
|
|
432
|
+
result.team_type = teamData.team_type;
|
|
433
|
+
result.members = teamData.members;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
return result;
|
|
410
437
|
}
|
|
411
438
|
|
|
412
439
|
async function handleAdvance(projectDir, args) {
|
|
@@ -914,14 +941,382 @@ async function handleScan(projectDir, args) {
|
|
|
914
941
|
const { scanProjectSecurity } = await import('../scanning/security-scanner.js');
|
|
915
942
|
return scanProjectSecurity(projectDir, { language: args.language, dir: args.dir });
|
|
916
943
|
}
|
|
944
|
+
case 'ui': {
|
|
945
|
+
const file = args.file;
|
|
946
|
+
if (!file) return errorResult('Missing --file flag for UI scan', 'MISSING_FILE');
|
|
947
|
+
const { scanUIQuality } = await import('../scanning/ui-scanner.js');
|
|
948
|
+
const { join: joinPath } = await import('path');
|
|
949
|
+
return scanUIQuality(joinPath(projectDir, file));
|
|
950
|
+
}
|
|
917
951
|
default:
|
|
918
|
-
return errorResult(`Unknown scan type: ${type}. Valid: placeholders, leakage, density, env, security`, 'UNKNOWN_SCAN_TYPE');
|
|
952
|
+
return errorResult(`Unknown scan type: ${type}. Valid: placeholders, leakage, density, env, security, ui`, 'UNKNOWN_SCAN_TYPE');
|
|
919
953
|
}
|
|
920
954
|
} catch (err) {
|
|
921
955
|
return errorResult(`Scan failed: ${err.message}`, 'SCAN_ERROR');
|
|
922
956
|
}
|
|
923
957
|
}
|
|
924
958
|
|
|
959
|
+
// ---------------------------------------------------------------------------
|
|
960
|
+
// Agent Teams Handlers (Article XXI)
|
|
961
|
+
// ---------------------------------------------------------------------------
|
|
962
|
+
|
|
963
|
+
const TEAM_CONFIGS = {
|
|
964
|
+
planning: {
|
|
965
|
+
members: ['detail', 'architect', 'ux'],
|
|
966
|
+
slug: 'pln',
|
|
967
|
+
templateFile: 'team-planning-tasks.yaml',
|
|
968
|
+
},
|
|
969
|
+
build: {
|
|
970
|
+
members: ['dev', 'qa-implementation'],
|
|
971
|
+
slug: 'bld',
|
|
972
|
+
templateFile: 'team-build-tasks.yaml',
|
|
973
|
+
},
|
|
974
|
+
};
|
|
975
|
+
|
|
976
|
+
function generateTeamId(slug) {
|
|
977
|
+
const date = new Date().toISOString().slice(0, 10).replace(/-/g, '');
|
|
978
|
+
return `TM-${date}-${slug}`;
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
function isAgentTeamsEnabled(projectDir) {
|
|
982
|
+
const configPath = join(projectDir, 'chati.dev', 'config.yaml');
|
|
983
|
+
if (!existsSync(configPath)) return false;
|
|
984
|
+
const raw = readFileSync(configPath, 'utf-8');
|
|
985
|
+
const match = raw.match(/agent_teams:\s*(true|false)/);
|
|
986
|
+
return match ? match[1] === 'true' : false;
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
async function handleSpawnTeam(projectDir, args) {
|
|
990
|
+
const teamType = args['team-type'];
|
|
991
|
+
if (!teamType || !TEAM_CONFIGS[teamType]) {
|
|
992
|
+
return errorResult('--team-type required (planning|build)', 'MISSING_TEAM_TYPE');
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
// Check feature flag
|
|
996
|
+
if (!isAgentTeamsEnabled(projectDir)) {
|
|
997
|
+
return {
|
|
998
|
+
action: 'spawn_parallel',
|
|
999
|
+
fallback_required: true,
|
|
1000
|
+
fallback_reason: 'agent_teams_disabled',
|
|
1001
|
+
};
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
const config = TEAM_CONFIGS[teamType];
|
|
1005
|
+
const teamId = generateTeamId(config.slug);
|
|
1006
|
+
const teamDir = join(projectDir, '.chati', 'teams', teamId);
|
|
1007
|
+
const mailboxDir = join(teamDir, 'mailbox');
|
|
1008
|
+
const taskListPath = join(teamDir, 'tasks.yaml');
|
|
1009
|
+
const templatePath = join(projectDir, 'chati.dev', 'templates', config.templateFile);
|
|
1010
|
+
|
|
1011
|
+
// Create team directories
|
|
1012
|
+
mkdirSync(mailboxDir, { recursive: true });
|
|
1013
|
+
|
|
1014
|
+
// Initialize task list from template
|
|
1015
|
+
try {
|
|
1016
|
+
initTaskList(taskListPath, templatePath, { team_id: teamId });
|
|
1017
|
+
|
|
1018
|
+
// For build teams: dynamically populate tasks from tasks.md (Article XXI §9)
|
|
1019
|
+
if (teamType === 'build') {
|
|
1020
|
+
const tasksArtifact = join(projectDir, 'chati.dev', 'artifacts', '6-Tasks', 'tasks.md');
|
|
1021
|
+
if (existsSync(tasksArtifact)) {
|
|
1022
|
+
const tasksContent = readFileSync(tasksArtifact, 'utf-8');
|
|
1023
|
+
// Extract task IDs (pattern: T{phase}.{seq})
|
|
1024
|
+
const taskMatches = tasksContent.match(/^#+\s*(T\d+\.\d+)[:\s—-]+(.+)$/gm) || [];
|
|
1025
|
+
const dynamicTasks = [];
|
|
1026
|
+
let seq = 1;
|
|
1027
|
+
|
|
1028
|
+
for (const match of taskMatches) {
|
|
1029
|
+
const m = match.match(/^#+\s*(T\d+\.\d+)[:\s—-]+(.+)$/);
|
|
1030
|
+
if (!m) continue;
|
|
1031
|
+
const [, taskRef, title] = m;
|
|
1032
|
+
const seqStr = String(seq).padStart(3, '0');
|
|
1033
|
+
|
|
1034
|
+
// Dev task
|
|
1035
|
+
dynamicTasks.push({
|
|
1036
|
+
id: `TT-BLD-${seqStr}-DEV`,
|
|
1037
|
+
title: `${taskRef}: ${title.trim()}`,
|
|
1038
|
+
assigned_to: 'dev',
|
|
1039
|
+
status: 'pending',
|
|
1040
|
+
score: null,
|
|
1041
|
+
blocker: null,
|
|
1042
|
+
source_task: taskRef,
|
|
1043
|
+
depends_on: seq > 1 ? [`TT-BLD-${String(seq - 1).padStart(3, '0')}-QA`] : [],
|
|
1044
|
+
threshold: 95,
|
|
1045
|
+
});
|
|
1046
|
+
|
|
1047
|
+
// QA review task
|
|
1048
|
+
dynamicTasks.push({
|
|
1049
|
+
id: `TT-BLD-${seqStr}-QA`,
|
|
1050
|
+
title: `${taskRef}: Review + approve`,
|
|
1051
|
+
assigned_to: 'qa-implementation',
|
|
1052
|
+
status: 'pending',
|
|
1053
|
+
score: null,
|
|
1054
|
+
blocker: null,
|
|
1055
|
+
source_task: taskRef,
|
|
1056
|
+
depends_on: [`TT-BLD-${seqStr}-DEV`],
|
|
1057
|
+
threshold: 95,
|
|
1058
|
+
});
|
|
1059
|
+
|
|
1060
|
+
seq++;
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
if (dynamicTasks.length > 0) {
|
|
1064
|
+
const taskList = readTaskList(taskListPath);
|
|
1065
|
+
if (taskList) {
|
|
1066
|
+
taskList.tasks = dynamicTasks;
|
|
1067
|
+
taskList.last_updated = new Date().toISOString();
|
|
1068
|
+
const yaml = await import('js-yaml');
|
|
1069
|
+
writeFileSync(taskListPath, yaml.default.dump(taskList, { lineWidth: -1, noRefs: true }), 'utf-8');
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
} catch (err) {
|
|
1075
|
+
return errorResult(`Failed to init team task list: ${err.message}`, 'TEAM_INIT_ERROR');
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
// Update session with team entry
|
|
1079
|
+
const previousAgent = args['previous-agent'] || 'none';
|
|
1080
|
+
const provider = args.provider || 'claude';
|
|
1081
|
+
|
|
1082
|
+
try {
|
|
1083
|
+
const { loaded, session } = loadSession(projectDir);
|
|
1084
|
+
if (loaded && session) {
|
|
1085
|
+
if (!session.teams) session.teams = [];
|
|
1086
|
+
session.teams.push({
|
|
1087
|
+
team_id: teamId,
|
|
1088
|
+
phase: teamType === 'planning' ? 'planning' : 'build',
|
|
1089
|
+
mission: teamType === 'planning'
|
|
1090
|
+
? 'Co-design PRD, architecture, and UX specification with cross-validated alignment'
|
|
1091
|
+
: 'Implement all tasks with per-task QA review and evidence-based quality gates',
|
|
1092
|
+
lead: 'orchestrator',
|
|
1093
|
+
roster: config.members,
|
|
1094
|
+
status: 'forming',
|
|
1095
|
+
task_list_path: `.chati/teams/${teamId}/tasks.yaml`,
|
|
1096
|
+
mailbox_path: `.chati/teams/${teamId}/mailbox/`,
|
|
1097
|
+
formed_at: new Date().toISOString(),
|
|
1098
|
+
dissolved_at: null,
|
|
1099
|
+
team_score: null,
|
|
1100
|
+
member_scores: {},
|
|
1101
|
+
correction_cycles: 0,
|
|
1102
|
+
echo_events: [],
|
|
1103
|
+
fallback_used: false,
|
|
1104
|
+
});
|
|
1105
|
+
|
|
1106
|
+
if (!session.team_events) session.team_events = [];
|
|
1107
|
+
session.team_events.push({
|
|
1108
|
+
timestamp: new Date().toISOString(),
|
|
1109
|
+
event: 'formed',
|
|
1110
|
+
team_id: teamId,
|
|
1111
|
+
trigger: `${teamType} team formation after ${previousAgent}`,
|
|
1112
|
+
});
|
|
1113
|
+
|
|
1114
|
+
await updateSession(projectDir, session);
|
|
1115
|
+
}
|
|
1116
|
+
} catch { /* non-critical: session update may fail, team can still spawn */ }
|
|
1117
|
+
|
|
1118
|
+
// Build spawn command
|
|
1119
|
+
const spawnCmd = [
|
|
1120
|
+
'node', join('packages', 'chati-dev', 'src', 'terminal', 'run-team.js'),
|
|
1121
|
+
'--team-id', teamId,
|
|
1122
|
+
'--team-type', teamType,
|
|
1123
|
+
'--project-dir', resolve(projectDir),
|
|
1124
|
+
'--previous-agent', previousAgent,
|
|
1125
|
+
'--provider', provider,
|
|
1126
|
+
'--timeout', args.timeout || '1800000',
|
|
1127
|
+
].join(' ');
|
|
1128
|
+
|
|
1129
|
+
return {
|
|
1130
|
+
action: 'spawn_team',
|
|
1131
|
+
team_id: teamId,
|
|
1132
|
+
team_type: teamType,
|
|
1133
|
+
members: config.members,
|
|
1134
|
+
task_list_path: `.chati/teams/${teamId}/tasks.yaml`,
|
|
1135
|
+
mailbox_path: `.chati/teams/${teamId}/mailbox/`,
|
|
1136
|
+
spawn_team_command: spawnCmd,
|
|
1137
|
+
poll_interval_ms: 5000,
|
|
1138
|
+
fallback_required: false,
|
|
1139
|
+
};
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
async function handleTeamStatus(projectDir, args) {
|
|
1143
|
+
const teamId = args['team-id'];
|
|
1144
|
+
if (!teamId) return errorResult('--team-id required', 'MISSING_TEAM_ID');
|
|
1145
|
+
|
|
1146
|
+
const taskListPath = join(projectDir, '.chati', 'teams', teamId, 'tasks.yaml');
|
|
1147
|
+
const progress = getTeamProgress(taskListPath);
|
|
1148
|
+
|
|
1149
|
+
// Read echo events from session
|
|
1150
|
+
let echoEvents = [];
|
|
1151
|
+
try {
|
|
1152
|
+
const { loaded, session } = loadSession(projectDir);
|
|
1153
|
+
if (loaded && session && session.teams) {
|
|
1154
|
+
const team = session.teams.find(t => t.team_id === teamId);
|
|
1155
|
+
if (team) echoEvents = team.echo_events || [];
|
|
1156
|
+
}
|
|
1157
|
+
} catch { /* non-critical */ }
|
|
1158
|
+
|
|
1159
|
+
return {
|
|
1160
|
+
team_id: teamId,
|
|
1161
|
+
status: progress.done === progress.total ? 'completing' : 'active',
|
|
1162
|
+
member_progress: progress.memberProgress,
|
|
1163
|
+
total_tasks: progress.total,
|
|
1164
|
+
completed_tasks: progress.done,
|
|
1165
|
+
pending_tasks: progress.pending,
|
|
1166
|
+
blocked_tasks: progress.blocked,
|
|
1167
|
+
echo_events: echoEvents,
|
|
1168
|
+
};
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
async function handleTeamDissolve(projectDir, args) {
|
|
1172
|
+
const teamId = args['team-id'];
|
|
1173
|
+
if (!teamId) return errorResult('--team-id required', 'MISSING_TEAM_ID');
|
|
1174
|
+
|
|
1175
|
+
const taskListPath = join(projectDir, '.chati', 'teams', teamId, 'tasks.yaml');
|
|
1176
|
+
const progress = getTeamProgress(taskListPath);
|
|
1177
|
+
|
|
1178
|
+
// Derive team type from team ID slug (for mailbox checks and tier threshold)
|
|
1179
|
+
const teamType = teamId.includes('-bld') ? 'build' : 'planning';
|
|
1180
|
+
|
|
1181
|
+
// Read echo events from session
|
|
1182
|
+
let echoEvents = [];
|
|
1183
|
+
try {
|
|
1184
|
+
const { loaded: echoLoaded, session: echoSession } = loadSession(projectDir);
|
|
1185
|
+
if (echoLoaded && echoSession && echoSession.teams) {
|
|
1186
|
+
const team = echoSession.teams.find(t => t.team_id === teamId);
|
|
1187
|
+
if (team) echoEvents = team.echo_events || [];
|
|
1188
|
+
}
|
|
1189
|
+
} catch { /* best-effort */ }
|
|
1190
|
+
|
|
1191
|
+
// Calculate team score
|
|
1192
|
+
const memberScores = {};
|
|
1193
|
+
let totalScore = 0;
|
|
1194
|
+
let scoreCount = 0;
|
|
1195
|
+
for (const [member, data] of Object.entries(progress.memberProgress)) {
|
|
1196
|
+
if (data.score !== null) {
|
|
1197
|
+
memberScores[member] = data.score;
|
|
1198
|
+
totalScore += data.score;
|
|
1199
|
+
scoreCount++;
|
|
1200
|
+
}
|
|
1201
|
+
}
|
|
1202
|
+
const teamScore = scoreCount > 0 ? Math.round((totalScore / scoreCount) * 10) / 10 : 0;
|
|
1203
|
+
|
|
1204
|
+
// Team Quality Gate — verify all 6 conditions (Article XXI section 6)
|
|
1205
|
+
const allDone = progress.done === progress.total;
|
|
1206
|
+
// Dynamic tier threshold per Article XXI §6: must meet highest tier in roster
|
|
1207
|
+
// QA agents (qa-planning, qa-implementation) require 95%, standard agents 90%
|
|
1208
|
+
const QA_AGENTS = ['qa-planning', 'qa-implementation'];
|
|
1209
|
+
const teamConfig = TEAM_CONFIGS[teamType];
|
|
1210
|
+
const hasQaAgent = teamConfig ? teamConfig.members.some(m => QA_AGENTS.includes(m)) : false;
|
|
1211
|
+
const tierThreshold = hasQaAgent ? 95 : 90;
|
|
1212
|
+
const allScoresPass = Object.values(memberScores).every(s => s >= tierThreshold);
|
|
1213
|
+
const noBlockers = progress.blocked === 0;
|
|
1214
|
+
|
|
1215
|
+
// Check mailbox cleanliness
|
|
1216
|
+
let mailboxClean = true;
|
|
1217
|
+
try {
|
|
1218
|
+
const { readInbox } = await import('../terminal/team-task-list.js');
|
|
1219
|
+
const mailboxDir = join(projectDir, '.chati', 'teams', teamId, 'mailbox');
|
|
1220
|
+
// Check all members for unresolved messages
|
|
1221
|
+
const dissolveTeamConfig = TEAM_CONFIGS[teamType];
|
|
1222
|
+
if (dissolveTeamConfig) {
|
|
1223
|
+
for (const member of dissolveTeamConfig.members) {
|
|
1224
|
+
const inbox = readInbox(mailboxDir, member);
|
|
1225
|
+
const unresolved = inbox.filter(m => m.type === 'task_review_findings' && m.payload?.verdict === 'block');
|
|
1226
|
+
if (unresolved.length > 0) { mailboxClean = false; break; }
|
|
1227
|
+
}
|
|
1228
|
+
}
|
|
1229
|
+
} catch { /* mailbox check is best-effort */ }
|
|
1230
|
+
|
|
1231
|
+
// Check correction cycles against config limit (Article XXI §6)
|
|
1232
|
+
let correctionCyclesOk = true;
|
|
1233
|
+
let maxCorrectionCycles = 2; // default per constitution
|
|
1234
|
+
try {
|
|
1235
|
+
const configPath = join(projectDir, 'chati.dev', 'config.yaml');
|
|
1236
|
+
if (existsSync(configPath)) {
|
|
1237
|
+
const configRaw = readFileSync(configPath, 'utf-8');
|
|
1238
|
+
const maxMatch = configRaw.match(/team_correction_cycles_max:\s*(\d+)/);
|
|
1239
|
+
if (maxMatch) maxCorrectionCycles = parseInt(maxMatch[1], 10);
|
|
1240
|
+
}
|
|
1241
|
+
} catch { /* use default */ }
|
|
1242
|
+
try {
|
|
1243
|
+
const { loaded, session: sess } = loadSession(projectDir);
|
|
1244
|
+
if (loaded && sess && sess.teams) {
|
|
1245
|
+
const team = sess.teams.find(t => t.team_id === teamId);
|
|
1246
|
+
if (team && team.correction_cycles > maxCorrectionCycles) correctionCyclesOk = false;
|
|
1247
|
+
}
|
|
1248
|
+
} catch { /* best-effort */ }
|
|
1249
|
+
|
|
1250
|
+
// Determine dissolution state based on all conditions
|
|
1251
|
+
const qualityGatePassed = allDone && allScoresPass && mailboxClean && noBlockers && correctionCyclesOk && teamScore >= tierThreshold;
|
|
1252
|
+
const state = qualityGatePassed ? 'clean' : 'degraded';
|
|
1253
|
+
const gateFailures = [];
|
|
1254
|
+
if (!allDone) gateFailures.push('incomplete_tasks');
|
|
1255
|
+
if (!allScoresPass) gateFailures.push('scores_below_threshold');
|
|
1256
|
+
if (!mailboxClean) gateFailures.push('unresolved_mailbox_messages');
|
|
1257
|
+
if (!noBlockers) gateFailures.push('open_blockers');
|
|
1258
|
+
if (!correctionCyclesOk) gateFailures.push('correction_cycles_exceeded');
|
|
1259
|
+
if (teamScore < 90) gateFailures.push('team_score_below_threshold');
|
|
1260
|
+
|
|
1261
|
+
// Update session
|
|
1262
|
+
try {
|
|
1263
|
+
const { loaded, session } = loadSession(projectDir);
|
|
1264
|
+
if (loaded && session && session.teams) {
|
|
1265
|
+
const team = session.teams.find(t => t.team_id === teamId);
|
|
1266
|
+
if (team) {
|
|
1267
|
+
team.status = state === 'clean' ? 'dissolved' : 'degraded';
|
|
1268
|
+
team.dissolved_at = new Date().toISOString();
|
|
1269
|
+
team.team_score = teamScore;
|
|
1270
|
+
team.member_scores = memberScores;
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
if (!session.team_events) session.team_events = [];
|
|
1274
|
+
session.team_events.push({
|
|
1275
|
+
timestamp: new Date().toISOString(),
|
|
1276
|
+
event: state === 'clean' ? 'dissolved' : 'degraded',
|
|
1277
|
+
team_id: teamId,
|
|
1278
|
+
trigger: state === 'clean' ? 'Team Quality Gate passed' : `Degraded dissolution — failures: ${gateFailures.join(', ')}`,
|
|
1279
|
+
state,
|
|
1280
|
+
});
|
|
1281
|
+
|
|
1282
|
+
// Sync member scores to agents block
|
|
1283
|
+
if (session.agents) {
|
|
1284
|
+
for (const [member, score] of Object.entries(memberScores)) {
|
|
1285
|
+
if (session.agents[member]) {
|
|
1286
|
+
session.agents[member].score = score;
|
|
1287
|
+
session.agents[member].status = 'completed';
|
|
1288
|
+
session.agents[member].completed_at = new Date().toISOString();
|
|
1289
|
+
}
|
|
1290
|
+
}
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
await updateSession(projectDir, session);
|
|
1294
|
+
}
|
|
1295
|
+
} catch { /* non-critical */ }
|
|
1296
|
+
|
|
1297
|
+
return {
|
|
1298
|
+
dissolved: true,
|
|
1299
|
+
team_id: teamId,
|
|
1300
|
+
team_score: teamScore,
|
|
1301
|
+
member_scores: memberScores,
|
|
1302
|
+
state,
|
|
1303
|
+
all_tasks_complete: allDone,
|
|
1304
|
+
echo_events: echoEvents,
|
|
1305
|
+
quality_gate: {
|
|
1306
|
+
passed: qualityGatePassed,
|
|
1307
|
+
failures: gateFailures,
|
|
1308
|
+
checks: {
|
|
1309
|
+
all_tasks_complete: allDone,
|
|
1310
|
+
all_scores_pass_threshold: allScoresPass,
|
|
1311
|
+
mailbox_clean: mailboxClean,
|
|
1312
|
+
no_open_blockers: noBlockers,
|
|
1313
|
+
correction_cycles_ok: correctionCyclesOk,
|
|
1314
|
+
team_score_above_threshold: teamScore >= 90,
|
|
1315
|
+
},
|
|
1316
|
+
},
|
|
1317
|
+
};
|
|
1318
|
+
}
|
|
1319
|
+
|
|
925
1320
|
// ---------------------------------------------------------------------------
|
|
926
1321
|
// Main entry point
|
|
927
1322
|
// ---------------------------------------------------------------------------
|
|
@@ -970,8 +1365,17 @@ export async function runOrchestrate(subCommand, argv, projectDir) {
|
|
|
970
1365
|
case 'scan':
|
|
971
1366
|
output(await handleScan(projectDir, args));
|
|
972
1367
|
break;
|
|
1368
|
+
case 'spawn-team':
|
|
1369
|
+
output(await handleSpawnTeam(projectDir, args));
|
|
1370
|
+
break;
|
|
1371
|
+
case 'team-status':
|
|
1372
|
+
output(await handleTeamStatus(projectDir, args));
|
|
1373
|
+
break;
|
|
1374
|
+
case 'team-dissolve':
|
|
1375
|
+
output(await handleTeamDissolve(projectDir, args));
|
|
1376
|
+
break;
|
|
973
1377
|
default:
|
|
974
|
-
output(errorResult(`Unknown sub-command: ${subCommand}. Valid: next, advance, init, validate-handoff, status, deviation, exit, providers, detect-flow, backlog, qa-plan-score, qa-impl-score, scan`, 'UNKNOWN_COMMAND'));
|
|
1378
|
+
output(errorResult(`Unknown sub-command: ${subCommand}. Valid: next, advance, init, validate-handoff, status, deviation, exit, providers, detect-flow, backlog, qa-plan-score, qa-impl-score, scan, spawn-team, team-status, team-dissolve`, 'UNKNOWN_COMMAND'));
|
|
975
1379
|
break;
|
|
976
1380
|
}
|
|
977
1381
|
} catch (err) {
|
|
@@ -30,7 +30,7 @@ export function executeHandoff(projectDir, params) {
|
|
|
30
30
|
const errors = [];
|
|
31
31
|
|
|
32
32
|
// Validate preconditions
|
|
33
|
-
const preconditions = validateHandoffPreconditions(params);
|
|
33
|
+
const preconditions = validateHandoffPreconditions(params, projectDir);
|
|
34
34
|
if (!preconditions.valid) {
|
|
35
35
|
return {
|
|
36
36
|
success: false,
|
|
@@ -113,7 +113,7 @@ export function executeHandoff(projectDir, params) {
|
|
|
113
113
|
* @param {object} params
|
|
114
114
|
* @returns {{ valid: boolean, issues: string[] }}
|
|
115
115
|
*/
|
|
116
|
-
export function validateHandoffPreconditions(params) {
|
|
116
|
+
export function validateHandoffPreconditions(params, projectDir = '.') {
|
|
117
117
|
const issues = [];
|
|
118
118
|
|
|
119
119
|
// Check validation exists and passed
|
|
@@ -148,6 +148,19 @@ export function validateHandoffPreconditions(params) {
|
|
|
148
148
|
issues.push('Handoff summary is required');
|
|
149
149
|
}
|
|
150
150
|
|
|
151
|
+
// Deterministic artifact checks for UX agent handoffs
|
|
152
|
+
const agent = params.fromTask?.agent || params.fromTask?.id?.split('-')[0] || '';
|
|
153
|
+
if (agent === 'ux') {
|
|
154
|
+
const brandbookPath = join(projectDir, 'chati.dev', 'artifacts', '4-UX', 'brandbook.html');
|
|
155
|
+
if (!existsSync(brandbookPath)) {
|
|
156
|
+
issues.push('brandbook.html is missing — UX handoff requires the visual brandbook file on disk');
|
|
157
|
+
}
|
|
158
|
+
const discoveryLogPath = join(projectDir, 'chati.dev', 'artifacts', '4-UX', 'component-discovery-log.md');
|
|
159
|
+
if (!existsSync(discoveryLogPath)) {
|
|
160
|
+
issues.push('component-discovery-log.md is missing — UX handoff requires component discovery documentation');
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
151
164
|
return {
|
|
152
165
|
valid: issues.length === 0,
|
|
153
166
|
issues,
|
|
@@ -319,6 +332,20 @@ export function validateHandoffIntegrity(handoff, receivingAgent) {
|
|
|
319
332
|
}
|
|
320
333
|
}
|
|
321
334
|
|
|
335
|
+
// Check for screenshot evidence from Dev agent (UI tasks)
|
|
336
|
+
if (handoff.from_agent === 'dev' || handoff.agent === 'dev') {
|
|
337
|
+
const hasUIOutputs = handoff.outputs && handoff.outputs.some(o =>
|
|
338
|
+
/\.(tsx|jsx|vue|svelte|css)$/i.test(o)
|
|
339
|
+
);
|
|
340
|
+
if (hasUIOutputs) {
|
|
341
|
+
const hasVisualEvidence = handoff.summary &&
|
|
342
|
+
(handoff.summary.includes('Visual review PASSED') || handoff.summary.includes('screenshot'));
|
|
343
|
+
if (!hasVisualEvidence) {
|
|
344
|
+
warnings.push('Dev agent produced UI files but no visual review evidence found in handoff — manual visual review recommended');
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
322
349
|
return {
|
|
323
350
|
valid: missing.length === 0,
|
|
324
351
|
missing,
|
package/src/scanning/index.js
CHANGED
|
@@ -5,3 +5,4 @@ export { scanImplementationLeakage, extractFRSection, TECH_NAMES } from './leaka
|
|
|
5
5
|
export { scanInformationDensity, FILLER_PATTERNS } from './density-scanner.js';
|
|
6
6
|
export { scanEnvSync, parseEnvFile, grepEnvUsage } from './env-scanner.js';
|
|
7
7
|
export { scanProjectSecurity, detectLanguage } from './security-scanner.js';
|
|
8
|
+
export { scanUIQuality, UI_QUALITY_CHECKS } from './ui-scanner.js';
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* UI Quality Scanner -- Detects hardcoded visual values in UI files.
|
|
3
|
+
* Deterministic regex scan. Zero LLM dependency.
|
|
4
|
+
*
|
|
5
|
+
* Catches common "vibecoded" patterns:
|
|
6
|
+
* - Hardcoded hex colors (should use design tokens)
|
|
7
|
+
* - Inline styles with px values (should use spacing tokens)
|
|
8
|
+
* - !important in CSS (code smell)
|
|
9
|
+
* - Images without alt attributes (accessibility)
|
|
10
|
+
* - Inline style= attributes (should use classes/tokens)
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { readFileSync } from 'fs';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* UI quality patterns. Each has an id, pattern, severity, and description.
|
|
17
|
+
*/
|
|
18
|
+
export const UI_QUALITY_CHECKS = [
|
|
19
|
+
{
|
|
20
|
+
id: 'HARDCODED_HEX_COLOR',
|
|
21
|
+
pattern: /(?<![\\w-])(?:color|background|border|fill|stroke)\s*:\s*#[0-9a-fA-F]{3,8}\b/g,
|
|
22
|
+
severity: 'high',
|
|
23
|
+
description: 'Hardcoded hex color in CSS property — use design tokens instead',
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
id: 'INLINE_STYLE',
|
|
27
|
+
pattern: /\bstyle\s*=\s*["'{]/g,
|
|
28
|
+
severity: 'medium',
|
|
29
|
+
description: 'Inline style attribute — use CSS classes or design tokens',
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
id: 'CSS_IMPORTANT',
|
|
33
|
+
pattern: /!\s*important/gi,
|
|
34
|
+
severity: 'medium',
|
|
35
|
+
description: '!important in CSS — indicates specificity problem',
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
id: 'IMG_MISSING_ALT',
|
|
39
|
+
pattern: /<img(?![^>]*\balt\s*=)[^>]*>/gi,
|
|
40
|
+
severity: 'high',
|
|
41
|
+
description: 'Image tag without alt attribute — accessibility violation',
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
id: 'HARDCODED_PX_INLINE',
|
|
45
|
+
pattern: /style\s*=\s*["'][^"']*\d+px[^"']*["']/g,
|
|
46
|
+
severity: 'medium',
|
|
47
|
+
description: 'Hardcoded px values in inline style — use spacing tokens',
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
id: 'HARDCODED_RGBA',
|
|
51
|
+
pattern: /(?<![\\w-])(?:color|background|border|fill|stroke)\s*:\s*rgba?\([^)]+\)/g,
|
|
52
|
+
severity: 'high',
|
|
53
|
+
description: 'Hardcoded rgba color — use design tokens instead',
|
|
54
|
+
},
|
|
55
|
+
];
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* File extensions this scanner targets.
|
|
59
|
+
*/
|
|
60
|
+
const UI_FILE_EXTENSIONS = ['.tsx', '.jsx', '.vue', '.svelte', '.css', '.scss'];
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Paths to skip (config files, design token definitions, tailwind config).
|
|
64
|
+
*/
|
|
65
|
+
const SKIP_PATTERNS = [
|
|
66
|
+
/tailwind\.config/,
|
|
67
|
+
/globals\.css$/,
|
|
68
|
+
/tokens?\./,
|
|
69
|
+
/theme\./,
|
|
70
|
+
/\.config\./,
|
|
71
|
+
/node_modules/,
|
|
72
|
+
/brandbook/,
|
|
73
|
+
];
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Check if a file should be scanned.
|
|
77
|
+
* @param {string} filePath
|
|
78
|
+
* @returns {boolean}
|
|
79
|
+
*/
|
|
80
|
+
function shouldScan(filePath) {
|
|
81
|
+
const hasUIExtension = UI_FILE_EXTENSIONS.some(ext => filePath.endsWith(ext));
|
|
82
|
+
const isSkipped = SKIP_PATTERNS.some(p => p.test(filePath));
|
|
83
|
+
return hasUIExtension && !isSkipped;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Scan a file for UI quality violations.
|
|
88
|
+
* @param {string} filePath - Path to file to scan
|
|
89
|
+
* @returns {{ clean: boolean, findings: Array<{id: string, match: string, line: number, severity: string, description: string}>, count: number, skipped: boolean }}
|
|
90
|
+
*/
|
|
91
|
+
export function scanUIQuality(filePath) {
|
|
92
|
+
if (!shouldScan(filePath)) {
|
|
93
|
+
return { clean: true, findings: [], count: 0, skipped: true };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const content = readFileSync(filePath, 'utf-8');
|
|
97
|
+
const findings = [];
|
|
98
|
+
|
|
99
|
+
for (const check of UI_QUALITY_CHECKS) {
|
|
100
|
+
const re = new RegExp(check.pattern.source, check.pattern.flags);
|
|
101
|
+
let match;
|
|
102
|
+
while ((match = re.exec(content)) !== null) {
|
|
103
|
+
const line = content.slice(0, match.index).split('\n').length;
|
|
104
|
+
findings.push({
|
|
105
|
+
id: check.id,
|
|
106
|
+
match: match[0].slice(0, 80),
|
|
107
|
+
line,
|
|
108
|
+
severity: check.severity,
|
|
109
|
+
description: check.description,
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return {
|
|
115
|
+
clean: findings.length === 0,
|
|
116
|
+
findings,
|
|
117
|
+
count: findings.length,
|
|
118
|
+
skipped: false,
|
|
119
|
+
};
|
|
120
|
+
}
|