feat(cli): 添加 --rule 选项支持模板自动发现

重构 ccw cli 模板系统:

- 新增 template-discovery.ts 模块,支持扁平化模板自动发现
- 添加 --rule <template> 选项,自动加载 protocol 和 template
- 模板目录从嵌套结构 (prompts/category/file.txt) 迁移到扁平结构 (prompts/category-function.txt)
- 更新所有 agent/command 文件,使用 $PROTO $TMPL 环境变量替代 $(cat ...) 模式
- 支持模糊匹配:--rule 02-review-architecture 可匹配 analysis-review-architecture.txt

其他更新:
- Dashboard: 添加 Claude Manager 和 Issue Manager 页面
- Codex-lens: 增强 chain_search 和 clustering 模块

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
catlog22
2026-01-17 19:20:24 +08:00
parent 1fae35c05d
commit f14418603a
137 changed files with 13125 additions and 301 deletions

View File

@@ -187,6 +187,13 @@ export function run(argv: string[]): void {
.option('--no-native', 'Force prompt concatenation instead of native resume')
.option('--cache [items]', 'Cache: comma-separated @patterns and text content')
.option('--inject-mode <mode>', 'Inject mode: none, full, progressive (default: codex=full, others=none)')
// Template/Rules options
.option('--rule <template>', 'Template name for auto-discovery (defines $PROTO and $TMPL env vars)')
// Codex review options
.option('--uncommitted', 'Review uncommitted changes (codex review)')
.option('--base <branch>', 'Review changes against base branch (codex review)')
.option('--commit <sha>', 'Review changes from specific commit (codex review)')
.option('--title <title>', 'Optional commit title for review summary (codex review)')
// Storage options
.option('--project <path>', 'Project path for storage operations')
.option('--force', 'Confirm destructive operations')

View File

@@ -124,6 +124,13 @@ interface CliExecOptions {
cache?: string | boolean; // Cache: true = auto from CONTEXT, string = comma-separated patterns/content
injectMode?: 'none' | 'full' | 'progressive'; // Inject mode for cached content
debug?: boolean; // Enable debug logging
// Codex review options
uncommitted?: boolean; // Review uncommitted changes (default for review mode)
base?: string; // Review changes against base branch
commit?: string; // Review changes from specific commit
title?: string; // Optional title for review summary
// Template/Rules options
rule?: string; // Template name for auto-discovery (defines $PROTO and $TMPL env vars)
}
/** Cache configuration parsed from --cache */
@@ -535,7 +542,7 @@ async function statusAction(debug?: boolean): Promise<void> {
* @param {Object} options - CLI options
*/
async function execAction(positionalPrompt: string | undefined, options: CliExecOptions): Promise<void> {
const { prompt: optionPrompt, file, tool = 'gemini', mode = 'analysis', model, cd, includeDirs, stream, resume, id, noNative, cache, injectMode, debug } = options;
const { prompt: optionPrompt, file, tool = 'gemini', mode = 'analysis', model, cd, includeDirs, stream, resume, id, noNative, cache, injectMode, debug, uncommitted, base, commit, title, rule } = options;
// Enable debug mode if --debug flag is set
if (debug) {
@@ -579,6 +586,25 @@ async function execAction(positionalPrompt: string | undefined, options: CliExec
const prompt_to_use = finalPrompt || '';
// Load rules templates if --rule is specified (will be passed as env vars)
let rulesEnv: { PROTO?: string; TMPL?: string } = {};
if (rule) {
try {
const { loadProtocol, loadTemplate } = await import('../tools/template-discovery.js');
const proto = loadProtocol(mode);
const tmpl = loadTemplate(rule);
if (proto) rulesEnv.PROTO = proto;
if (tmpl) rulesEnv.TMPL = tmpl;
if (debug) {
console.log(chalk.gray(` Rule loaded: PROTO(${proto ? proto.length : 0} chars) + TMPL(${tmpl ? tmpl.length : 0} chars)`));
console.log(chalk.gray(` Use $PROTO and $TMPL in your prompt to reference them`));
}
} catch (error) {
console.error(chalk.red(`Error loading rule template: ${error instanceof Error ? error.message : error}`));
process.exit(1);
}
}
// Handle cache option: pack @patterns and/or content
let cacheSessionId: string | undefined;
let actualPrompt = prompt_to_use;
@@ -847,7 +873,14 @@ async function execAction(positionalPrompt: string | undefined, options: CliExec
id, // custom execution ID
noNative,
stream: !!stream, // stream=true → streaming enabled (no cache), stream=false → cache output (default)
outputFormat // Enable JSONL parsing for tools that support it
outputFormat, // Enable JSONL parsing for tools that support it
// Codex review options
uncommitted,
base,
commit,
title,
// Rules env vars (PROTO, TMPL)
rulesEnv: Object.keys(rulesEnv).length > 0 ? rulesEnv : undefined
}, onOutput); // Always pass onOutput for real-time dashboard streaming
if (elapsedInterval) clearInterval(elapsedInterval);

View File

@@ -811,6 +811,56 @@ export async function handleClaudeRoutes(ctx: RouteContext): Promise<boolean> {
return true;
}
// API: Batch delete files
if (pathname === '/api/memory/claude/batch-delete' && req.method === 'POST') {
handlePostRequest(req, res, async (body: any) => {
const { paths, confirm } = body;
if (!paths || !Array.isArray(paths) || paths.length === 0) {
return { error: 'paths array is required', status: 400 };
}
if (confirm !== true) {
return { error: 'Confirmation required', status: 400 };
}
const results = {
success: true,
total: paths.length,
deleted: 0,
errors: [] as Array<{ path: string; error: string }>
};
// Delete each file
for (const filePath of paths) {
const result = deleteClaudeFile(filePath);
if (result.success) {
results.deleted++;
// Broadcast individual file deletion
broadcastToClients({
type: 'CLAUDE_FILE_DELETED',
data: { path: filePath }
});
} else {
results.errors.push({ path: filePath, error: result.error || 'Unknown error' });
}
}
// Broadcast batch deletion completion
broadcastToClients({
type: 'CLAUDE_BATCH_DELETED',
data: {
total: results.total,
deleted: results.deleted,
failed: results.errors.length
}
});
return results;
});
return true;
}
// API: Create file
if (pathname === '/api/memory/claude/create' && req.method === 'POST') {
handlePostRequest(req, res, async (body: any) => {

View File

@@ -79,6 +79,12 @@ function writeSolutionsJsonl(issuesDir: string, issueId: string, solutions: any[
writeFileSync(join(solutionsDir, `${issueId}.jsonl`), solutions.map(s => JSON.stringify(s)).join('\n'));
}
function generateQueueFileId(): string {
const now = new Date();
const ts = now.toISOString().replace(/[-:T]/g, '').slice(0, 14);
return `QUE-${ts}`;
}
function readQueue(issuesDir: string) {
// Try new multi-queue structure first
const queuesDir = join(issuesDir, 'queues');
@@ -718,6 +724,183 @@ export async function handleIssueRoutes(ctx: RouteContext): Promise<boolean> {
return true;
}
// POST /api/queue/split - Split items from source queue into a new queue
if (pathname === '/api/queue/split' && req.method === 'POST') {
handlePostRequest(req, res, async (body: any) => {
const { sourceQueueId, itemIds } = body;
if (!sourceQueueId || !itemIds || !Array.isArray(itemIds) || itemIds.length === 0) {
return { error: 'sourceQueueId and itemIds (non-empty array) required' };
}
const queuesDir = join(issuesDir, 'queues');
const sourcePath = join(queuesDir, `${sourceQueueId}.json`);
if (!existsSync(sourcePath)) {
return { error: `Source queue ${sourceQueueId} not found` };
}
try {
const sourceQueue = JSON.parse(readFileSync(sourcePath, 'utf8'));
const sourceItems = sourceQueue.solutions || sourceQueue.tasks || [];
const isSolutionBased = !!sourceQueue.solutions;
// Find items to split
const itemsToSplit = sourceItems.filter((item: any) =>
itemIds.includes(item.item_id) ||
itemIds.includes(item.solution_id) ||
itemIds.includes(item.task_id)
);
if (itemsToSplit.length === 0) {
return { error: 'No matching items found to split' };
}
if (itemsToSplit.length === sourceItems.length) {
return { error: 'Cannot split all items - at least one item must remain in source queue' };
}
// Find remaining items
const remainingItems = sourceItems.filter((item: any) =>
!itemIds.includes(item.item_id) &&
!itemIds.includes(item.solution_id) &&
!itemIds.includes(item.task_id)
);
// Create new queue with split items
const newQueueId = generateQueueFileId();
const newQueuePath = join(queuesDir, `${newQueueId}.json`);
// Re-index split items
const reindexedSplitItems = itemsToSplit.map((item: any, idx: number) => ({
...item,
execution_order: idx + 1
}));
// Extract issue IDs from split items
const splitIssueIds = [...new Set(itemsToSplit.map((item: any) => item.issue_id).filter(Boolean))];
// Remaining issue IDs
const remainingIssueIds = [...new Set(remainingItems.map((item: any) => item.issue_id).filter(Boolean))];
// Create new queue
const newQueue: any = {
id: newQueueId,
status: 'active',
issue_ids: splitIssueIds,
conflicts: [],
_metadata: {
version: '2.1',
updated_at: new Date().toISOString(),
split_from: sourceQueueId,
split_at: new Date().toISOString(),
...(isSolutionBased
? {
total_solutions: reindexedSplitItems.length,
completed_solutions: reindexedSplitItems.filter((i: any) => i.status === 'completed').length
}
: {
total_tasks: reindexedSplitItems.length,
completed_tasks: reindexedSplitItems.filter((i: any) => i.status === 'completed').length
})
}
};
if (isSolutionBased) {
newQueue.solutions = reindexedSplitItems;
} else {
newQueue.tasks = reindexedSplitItems;
}
// Update source queue with remaining items
const reindexedRemainingItems = remainingItems.map((item: any, idx: number) => ({
...item,
execution_order: idx + 1
}));
if (isSolutionBased) {
sourceQueue.solutions = reindexedRemainingItems;
} else {
sourceQueue.tasks = reindexedRemainingItems;
}
sourceQueue.issue_ids = remainingIssueIds;
sourceQueue._metadata = {
...sourceQueue._metadata,
updated_at: new Date().toISOString(),
...(isSolutionBased
? {
total_solutions: reindexedRemainingItems.length,
completed_solutions: reindexedRemainingItems.filter((i: any) => i.status === 'completed').length
}
: {
total_tasks: reindexedRemainingItems.length,
completed_tasks: reindexedRemainingItems.filter((i: any) => i.status === 'completed').length
})
};
// Write both queues
writeFileSync(newQueuePath, JSON.stringify(newQueue, null, 2));
writeFileSync(sourcePath, JSON.stringify(sourceQueue, null, 2));
// Update index
const indexPath = join(queuesDir, 'index.json');
if (existsSync(indexPath)) {
try {
const index = JSON.parse(readFileSync(indexPath, 'utf8'));
// Add new queue to index
const newQueueEntry: any = {
id: newQueueId,
status: 'active',
issue_ids: splitIssueIds,
created_at: new Date().toISOString(),
...(isSolutionBased
? {
total_solutions: reindexedSplitItems.length,
completed_solutions: reindexedSplitItems.filter((i: any) => i.status === 'completed').length
}
: {
total_tasks: reindexedSplitItems.length,
completed_tasks: reindexedSplitItems.filter((i: any) => i.status === 'completed').length
})
};
index.queues = index.queues || [];
index.queues.push(newQueueEntry);
// Update source queue in index
const sourceEntry = index.queues.find((q: any) => q.id === sourceQueueId);
if (sourceEntry) {
sourceEntry.issue_ids = remainingIssueIds;
if (isSolutionBased) {
sourceEntry.total_solutions = reindexedRemainingItems.length;
sourceEntry.completed_solutions = reindexedRemainingItems.filter((i: any) => i.status === 'completed').length;
} else {
sourceEntry.total_tasks = reindexedRemainingItems.length;
sourceEntry.completed_tasks = reindexedRemainingItems.filter((i: any) => i.status === 'completed').length;
}
}
writeFileSync(indexPath, JSON.stringify(index, null, 2));
} catch {
// Ignore index update errors
}
}
return {
success: true,
sourceQueueId,
newQueueId,
splitItemCount: itemsToSplit.length,
remainingItemCount: remainingItems.length
};
} catch (err) {
return { error: 'Failed to split queue' };
}
});
return true;
}
// Legacy: GET /api/issues/queue (backward compat)
if (pathname === '/api/issues/queue' && req.method === 'GET') {
const queue = groupQueueByExecutionGroup(readQueue(issuesDir));

View File

@@ -75,10 +75,13 @@ async function getSessionDetailData(sessionPath: string, dataType: string): Prom
}
}
// Load summaries from .summaries/
// Load summaries from .summaries/ and fallback to plan.json
if (dataType === 'summary' || dataType === 'all') {
const summariesDir = join(normalizedPath, '.summaries');
result.summaries = [];
result.summary = null; // Single summary text from plan.json
// 1. Try to load from .summaries/ directory
if (await fileExists(summariesDir)) {
const files = (await readdir(summariesDir)).filter(f => f.endsWith('.md'));
for (const file of files) {
@@ -90,6 +93,26 @@ async function getSessionDetailData(sessionPath: string, dataType: string): Prom
}
}
}
// 2. Fallback: Try to get summary from plan.json (for lite-fix-plan sessions)
if (result.summaries.length === 0) {
const planFile = join(normalizedPath, 'plan.json');
if (await fileExists(planFile)) {
try {
const planData = JSON.parse(await readFile(planFile, 'utf8'));
// Check plan.summary
if (planData.summary) {
result.summary = planData.summary;
}
// Check synthesis.convergence.summary
if (!result.summary && planData.synthesis?.convergence?.summary) {
result.summary = planData.synthesis.convergence.summary;
}
} catch (e) {
console.warn('Failed to parse plan file for summary:', planFile, (e as Error).message);
}
}
}
}
// Load plan.json (for lite tasks)

View File

@@ -906,3 +906,182 @@
max-height: 300px;
}
}
/* ========================================
* Batch Delete Modal
* ======================================== */
.batch-delete-modal {
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.warning-banner {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 1rem;
background: hsl(var(--destructive) / 0.1);
border: 1px solid hsl(var(--destructive) / 0.3);
border-radius: 0.5rem;
color: hsl(var(--destructive));
font-weight: 500;
}
.warning-banner i {
flex-shrink: 0;
}
.delete-summary {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1rem;
padding: 1rem;
background: hsl(var(--muted) / 0.3);
border-radius: 0.5rem;
}
.summary-item {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.summary-label {
font-size: 0.75rem;
color: hsl(var(--muted-foreground));
text-transform: uppercase;
letter-spacing: 0.025em;
font-weight: 500;
}
.summary-value {
font-size: 1.5rem;
font-weight: 600;
color: hsl(var(--foreground));
}
.file-list-container h4 {
font-size: 0.875rem;
font-weight: 600;
color: hsl(var(--foreground));
margin-bottom: 0.75rem;
}
.file-list {
max-height: 300px;
overflow-y: auto;
border: 1px solid hsl(var(--border));
border-radius: 0.5rem;
padding: 0.5rem;
background: hsl(var(--muted) / 0.2);
}
.delete-file-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem;
background: hsl(var(--card));
border: 1px solid hsl(var(--border));
border-radius: 0.375rem;
margin-bottom: 0.5rem;
transition: all 0.15s ease;
}
.delete-file-item:last-child {
margin-bottom: 0;
}
.delete-file-item:hover {
background: hsl(var(--hover));
}
.delete-file-item i {
flex-shrink: 0;
color: hsl(var(--muted-foreground));
}
.file-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.25rem;
min-width: 0;
}
.file-info .file-name {
font-weight: 500;
color: hsl(var(--foreground));
font-size: 0.875rem;
}
.file-info .file-path {
font-size: 0.75rem;
color: hsl(var(--muted-foreground));
font-family: 'Courier New', monospace;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.level-badge {
display: inline-flex;
align-items: center;
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
font-size: 0.75rem;
font-weight: 500;
white-space: nowrap;
flex-shrink: 0;
}
.level-badge.project {
background: hsl(142, 76%, 36%, 0.15);
color: hsl(142, 76%, 36%);
border: 1px solid hsl(142, 76%, 36%, 0.3);
}
.level-badge.module {
background: hsl(221, 83%, 53%, 0.15);
color: hsl(221, 83%, 53%);
border: 1px solid hsl(221, 83%, 53%, 0.3);
}
.confirmation-actions {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
padding-top: 0.75rem;
border-top: 1px solid hsl(var(--border));
}
/* Remove file button in batch delete list */
.remove-file-btn {
padding: 0.25rem;
border-radius: 0.25rem;
color: hsl(var(--muted-foreground));
background: transparent;
border: none;
cursor: pointer;
opacity: 0;
transition: all 0.15s ease;
flex-shrink: 0;
}
.delete-file-item:hover .remove-file-btn {
opacity: 1;
}
.remove-file-btn:hover {
background: hsl(var(--destructive) / 0.1);
color: hsl(var(--destructive));
}
/* Empty list message */
.empty-list-message {
padding: 2rem;
text-align: center;
color: hsl(var(--muted-foreground));
font-style: italic;
}

View File

@@ -3300,3 +3300,93 @@
text-transform: uppercase;
letter-spacing: 0.025em;
}
/* ==========================================
SPLIT QUEUE MODAL STYLES
========================================== */
.split-queue-modal-content {
max-width: 600px;
width: 90%;
}
.split-queue-controls {
display: flex;
gap: 0.5rem;
align-items: center;
}
.split-queue-issues {
display: flex;
flex-direction: column;
gap: 1rem;
}
.split-queue-issue-group {
border: 1px solid hsl(var(--border));
border-radius: 0.5rem;
padding: 0.75rem;
background: hsl(var(--muted) / 0.3);
transition: all 0.15s ease;
}
.split-queue-issue-group:hover {
background: hsl(var(--muted) / 0.5);
border-color: hsl(var(--primary) / 0.3);
}
.split-queue-issue-header {
margin-bottom: 0.5rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid hsl(var(--border) / 0.5);
}
.split-queue-issue-header label {
cursor: pointer;
user-select: none;
}
.split-queue-issue-header input[type="checkbox"] {
cursor: pointer;
}
.split-queue-solutions {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.split-queue-solutions label {
cursor: pointer;
user-select: none;
padding: 0.25rem;
border-radius: 0.25rem;
transition: background-color 0.15s ease;
}
.split-queue-solutions label:hover {
background: hsl(var(--muted) / 0.5);
}
.split-queue-solutions input[type="checkbox"] {
cursor: pointer;
}
/* Checkbox styles */
.split-queue-modal-content input[type="checkbox"] {
width: 1rem;
height: 1rem;
border: 1px solid hsl(var(--border));
border-radius: 0.25rem;
cursor: pointer;
transition: all 0.15s ease;
}
.split-queue-modal-content input[type="checkbox"]:hover {
border-color: hsl(var(--primary));
}
.split-queue-modal-content input[type="checkbox"]:checked {
background-color: hsl(var(--primary));
border-color: hsl(var(--primary));
}

View File

@@ -1704,6 +1704,19 @@ const i18n = {
'claude.deleteFile': 'Delete File',
'claude.deleteConfirm': 'Are you sure you want to delete {file}?',
'claude.deleteWarning': 'This action cannot be undone.',
'claude.batchDeleteProject': 'Delete Project Files',
'claude.batchDeleteTitle': 'Delete Project Workspace Files',
'claude.batchDeleteWarning': 'This will delete all CLAUDE.md files in the project workspace (excluding user-level files)',
'claude.noProjectFiles': 'No project workspace files to delete',
'claude.filesToDelete': 'Files to delete:',
'claude.totalSize': 'Total size:',
'claude.fileList': 'File List',
'claude.confirmDelete': 'Confirm Delete',
'claude.deletingFiles': 'Deleting {count} files...',
'claude.batchDeleteSuccess': 'Successfully deleted {deleted} of {total} files',
'claude.batchDeleteError': 'Failed to delete files',
'claude.removeFromList': 'Remove from list',
'claude.noFilesInList': 'No files in the list',
'claude.copyContent': 'Copy Content',
'claude.contentCopied': 'Content copied to clipboard',
'claude.copyError': 'Failed to copy content',
@@ -4013,6 +4026,19 @@ const i18n = {
'claude.deleteFile': '删除文件',
'claude.deleteConfirm': '确定要删除 {file} 吗?',
'claude.deleteWarning': '此操作无法撤销。',
'claude.batchDeleteProject': '删除项目文件',
'claude.batchDeleteTitle': '删除项目工作空间文件',
'claude.batchDeleteWarning': '此操作将删除项目工作空间内的所有 CLAUDE.md 文件(不包括用户级文件)',
'claude.noProjectFiles': '没有可删除的项目工作空间文件',
'claude.filesToDelete': '待删除文件数:',
'claude.totalSize': '总大小:',
'claude.fileList': '文件清单',
'claude.confirmDelete': '确认删除',
'claude.deletingFiles': '正在删除 {count} 个文件...',
'claude.batchDeleteSuccess': '成功删除 {deleted}/{total} 个文件',
'claude.batchDeleteError': '删除文件失败',
'claude.removeFromList': '从清单中移除',
'claude.noFilesInList': '清单中没有文件',
'claude.copyContent': '复制内容',
'claude.contentCopied': '内容已复制到剪贴板',
'claude.copyError': '复制内容失败',

View File

@@ -24,6 +24,7 @@ var searchQuery = '';
var freshnessData = {}; // { [filePath]: FreshnessResult }
var freshnessSummary = null;
var searchKeyboardHandlerAdded = false;
var pendingDeleteFiles = []; // Files pending for batch delete
// ========== Main Render Function ==========
async function renderClaudeManager() {
@@ -64,6 +65,9 @@ async function renderClaudeManager() {
'<button class="btn btn-sm btn-secondary" onclick="refreshClaudeFiles()">' +
'<i data-lucide="refresh-cw" class="w-4 h-4"></i> ' + t('common.refresh') +
'</button>' +
'<button class="btn btn-sm btn-danger" onclick="showBatchDeleteDialog()">' +
'<i data-lucide="trash-2" class="w-4 h-4"></i> ' + t('claude.batchDeleteProject') +
'</button>' +
'</div>' +
'</div>' +
'<div class="claude-manager-columns">' +
@@ -959,3 +963,167 @@ window.initClaudeManager = function() {
// Make destroyClaudeManager accessible globally as well
window.destroyClaudeManager = destroyClaudeManager;
// ========== Batch Delete Functions ==========
/**
* Show batch delete confirmation dialog for project workspace files
*/
function showBatchDeleteDialog() {
// Get project workspace files (project + modules, exclude user)
var projectFiles = [];
if (claudeFilesData.project.main) {
projectFiles.push(claudeFilesData.project.main);
}
projectFiles.push(...claudeFilesData.modules);
if (projectFiles.length === 0) {
showRefreshToast(t('claude.noProjectFiles') || 'No project workspace files to delete', 'info');
return;
}
// Initialize pending delete files list
pendingDeleteFiles = [...projectFiles];
// Render the modal with current pending files
renderBatchDeleteModal();
}
/**
* Render or re-render the batch delete modal content
*/
function renderBatchDeleteModal() {
// Build file list HTML with remove buttons
var fileListHTML = pendingDeleteFiles.map(function(file, index) {
var levelBadge = file.level === 'project'
? '<span class="level-badge project">' + t('claudeManager.projectLevel') + '</span>'
: '<span class="level-badge module">' + t('claudeManager.moduleLevel') + '</span>';
return '<div class="delete-file-item" data-file-index="' + index + '">' +
'<i data-lucide="file-text" class="w-4 h-4"></i>' +
'<div class="file-info">' +
'<span class="file-name">' + escapeHtml(file.name) + '</span>' +
'<span class="file-path">' + escapeHtml(file.relativePath) + '</span>' +
'</div>' +
levelBadge +
'<button class="btn btn-sm btn-ghost remove-file-btn" onclick="removeFromDeleteList(' + index + ')" title="' + (t('claude.removeFromList') || 'Remove from list') + '">' +
'<i data-lucide="x" class="w-4 h-4"></i>' +
'</button>' +
'</div>';
}).join('');
var totalSize = pendingDeleteFiles.reduce(function(sum, f) { return sum + f.size; }, 0);
var modalContent = '<div class="batch-delete-modal">' +
'<div class="warning-banner">' +
'<i data-lucide="alert-triangle" class="w-5 h-5"></i>' +
'<span>' + (t('claude.batchDeleteWarning') || 'This will delete all CLAUDE.md files in the project workspace') + '</span>' +
'</div>' +
'<div class="delete-summary" id="delete-summary">' +
'<div class="summary-item">' +
'<span class="summary-label">' + t('claude.filesToDelete') + '</span>' +
'<span class="summary-value" id="files-to-delete-count">' + pendingDeleteFiles.length + '</span>' +
'</div>' +
'<div class="summary-item">' +
'<span class="summary-label">' + t('claude.totalSize') + '</span>' +
'<span class="summary-value" id="total-size-value">' + formatFileSize(totalSize) + '</span>' +
'</div>' +
'</div>' +
'<div class="file-list-container">' +
'<h4>' + t('claude.fileList') + '</h4>' +
'<div class="file-list" id="pending-file-list">' + (fileListHTML || '<div class="empty-list-message">' + (t('claude.noFilesInList') || 'No files in the list') + '</div>') + '</div>' +
'</div>' +
'<div class="confirmation-actions">' +
'<button class="btn btn-secondary" onclick="closeModal()">' +
'<i data-lucide="x" class="w-4 h-4"></i> ' + t('common.cancel') +
'</button>' +
'<button class="btn btn-danger" onclick="confirmBatchDeleteProject()"' + (pendingDeleteFiles.length === 0 ? ' disabled' : '') + '>' +
'<i data-lucide="trash-2" class="w-4 h-4"></i> ' + t('claude.confirmDelete') +
'</button>' +
'</div>' +
'</div>';
showModal(
t('claude.batchDeleteTitle') || 'Delete Project Workspace Files',
modalContent,
{ size: 'large' }
);
if (window.lucide) lucide.createIcons();
}
/**
* Remove a file from the pending delete list
*/
function removeFromDeleteList(index) {
if (index >= 0 && index < pendingDeleteFiles.length) {
pendingDeleteFiles.splice(index, 1);
renderBatchDeleteModal();
}
}
/**
* Execute batch delete for project workspace files
*/
async function confirmBatchDeleteProject() {
// Collect file paths from pending delete list
var filePaths = pendingDeleteFiles.map(function(file) {
return file.path;
});
if (filePaths.length === 0) return;
closeModal();
// Show progress
showRefreshToast(
(t('claude.deletingFiles') || 'Deleting {count} files...').replace('{count}', filePaths.length),
'info'
);
try {
var res = await fetch('/api/memory/claude/batch-delete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
paths: filePaths,
confirm: true
})
});
if (!res.ok) throw new Error('Batch delete failed');
var result = await res.json();
if (result.success) {
var message = (t('claude.batchDeleteSuccess') || 'Successfully deleted {deleted} of {total} files')
.replace('{deleted}', result.deleted)
.replace('{total}', result.total);
showRefreshToast(message, 'success');
addGlobalNotification('success', message, null, 'CLAUDE.md');
if (result.errors && result.errors.length > 0) {
console.warn('Some files failed to delete:', result.errors);
}
// Clear selection if deleted file was selected
if (selectedFile && filePaths.includes(selectedFile.path)) {
selectedFile = null;
}
// Refresh file tree
await refreshClaudeFiles();
} else {
throw new Error(result.error || 'Unknown error');
}
} catch (error) {
console.error('Error in batch delete:', error);
showRefreshToast(
t('claude.batchDeleteError') || 'Failed to delete files',
'error'
);
addGlobalNotification('error', t('claude.batchDeleteError') || 'Failed to delete files', null, 'CLAUDE.md');
}
}

View File

@@ -562,6 +562,11 @@ function renderQueueCard(queue, isActive) {
<i data-lucide="git-merge" class="w-3 h-3"></i>
</button>
` : ''}
${queue.status !== 'merged' && issueCount > 1 ? `
<button class="btn-sm" onclick="showSplitQueueModal('${safeQueueId}')" title="Split queue into multiple queues">
<i data-lucide="git-branch" class="w-3 h-3"></i>
</button>
` : ''}
<button class="btn-sm btn-danger" onclick="confirmDeleteQueue('${safeQueueId}')" title="${t('issues.deleteQueue') || 'Delete queue'}">
<i data-lucide="trash-2" class="w-3 h-3"></i>
</button>
@@ -989,6 +994,188 @@ async function executeQueueMerge(sourceQueueId) {
}
}
// ========== Queue Split Modal ==========
async function showSplitQueueModal(queueId) {
let modal = document.getElementById('splitQueueModal');
if (!modal) {
modal = document.createElement('div');
modal.id = 'splitQueueModal';
modal.className = 'issue-modal';
document.body.appendChild(modal);
}
// Fetch queue details
let queue;
try {
const response = await fetch('/api/queue/' + encodeURIComponent(queueId) + '?path=' + encodeURIComponent(projectPath));
queue = await response.json();
if (queue.error) throw new Error(queue.error);
} catch (err) {
showNotification('Failed to load queue details', 'error');
return;
}
const safeQueueId = escapeHtml(queueId || '');
const items = queue.solutions || queue.tasks || [];
const isSolutionLevel = !!queue.solutions;
// Group items by issue
const issueGroups = {};
items.forEach(item => {
const issueId = item.issue_id || 'unknown';
if (!issueGroups[issueId]) {
issueGroups[issueId] = [];
}
issueGroups[issueId].push(item);
});
const issueIds = Object.keys(issueGroups);
modal.innerHTML = `
<div class="issue-modal-content split-queue-modal-content">
<div class="issue-modal-header">
<h3><i data-lucide="git-branch" class="w-5 h-5"></i> Split Queue: ${safeQueueId}</h3>
<button class="issue-modal-close" onclick="hideSplitQueueModal()">
<i data-lucide="x" class="w-5 h-5"></i>
</button>
</div>
<div class="issue-modal-body">
<p class="text-sm text-muted-foreground mb-4">
Select issues and their solutions to split into a new queue. The remaining items will stay in the current queue.
</p>
${issueIds.length === 0 ? `
<p class="text-center text-muted-foreground py-4">No items to split</p>
` : `
<div class="split-queue-controls mb-3">
<button class="btn-sm btn-secondary" onclick="selectAllIssues()">
<i data-lucide="check-square" class="w-3 h-3"></i> Select All
</button>
<button class="btn-sm btn-secondary" onclick="deselectAllIssues()">
<i data-lucide="square" class="w-3 h-3"></i> Deselect All
</button>
</div>
<div class="split-queue-issues">
${issueIds.map(issueId => {
const issueItems = issueGroups[issueId];
const safeIssueId = escapeHtml(issueId);
return `
<div class="split-queue-issue-group" data-issue-id="${safeIssueId}">
<div class="split-queue-issue-header">
<label class="flex items-center gap-2">
<input type="checkbox"
class="issue-checkbox"
data-issue-id="${safeIssueId}"
onchange="toggleIssueSelection('${safeIssueId}')">
<span class="font-medium">${safeIssueId}</span>
<span class="text-xs text-muted-foreground">(${issueItems.length} ${isSolutionLevel ? 'solution' : 'task'}${issueItems.length > 1 ? 's' : ''})</span>
</label>
</div>
<div class="split-queue-solutions ml-6">
${issueItems.map(item => {
const itemId = item.item_id || item.solution_id || item.task_id || '';
const safeItemId = escapeHtml(itemId);
const displayName = isSolutionLevel
? (item.solution_id || itemId)
: (item.task_id || itemId);
return `
<label class="flex items-center gap-2 py-1">
<input type="checkbox"
class="solution-checkbox"
data-issue-id="${safeIssueId}"
data-item-id="${safeItemId}"
value="${safeItemId}">
<span class="text-sm font-mono">${escapeHtml(displayName)}</span>
${item.task_count ? `<span class="text-xs text-muted-foreground">(${item.task_count} tasks)</span>` : ''}
</label>
`;
}).join('')}
</div>
</div>
`;
}).join('')}
</div>
`}
</div>
<div class="issue-modal-footer">
<button class="btn-secondary" onclick="hideSplitQueueModal()">Cancel</button>
${issueIds.length > 0 ? `
<button class="btn-primary" onclick="executeQueueSplit('${safeQueueId}')">
<i data-lucide="git-branch" class="w-4 h-4"></i>
<span>Split Queue</span>
</button>
` : ''}
</div>
</div>
`;
modal.classList.remove('hidden');
lucide.createIcons();
}
function hideSplitQueueModal() {
const modal = document.getElementById('splitQueueModal');
if (modal) {
modal.classList.add('hidden');
}
}
function toggleIssueSelection(issueId) {
const issueCheckbox = document.querySelector(`.issue-checkbox[data-issue-id="${issueId}"]`);
const solutionCheckboxes = document.querySelectorAll(`.solution-checkbox[data-issue-id="${issueId}"]`);
if (issueCheckbox && solutionCheckboxes) {
solutionCheckboxes.forEach(cb => {
cb.checked = issueCheckbox.checked;
});
}
}
function selectAllIssues() {
const allCheckboxes = document.querySelectorAll('.split-queue-modal-content input[type="checkbox"]');
allCheckboxes.forEach(cb => cb.checked = true);
}
function deselectAllIssues() {
const allCheckboxes = document.querySelectorAll('.split-queue-modal-content input[type="checkbox"]');
allCheckboxes.forEach(cb => cb.checked = false);
}
async function executeQueueSplit(sourceQueueId) {
const selectedCheckboxes = document.querySelectorAll('.solution-checkbox:checked');
const selectedItemIds = Array.from(selectedCheckboxes).map(cb => cb.value);
if (selectedItemIds.length === 0) {
showNotification('Please select at least one item to split', 'warning');
return;
}
try {
const response = await fetch('/api/queue/split?path=' + encodeURIComponent(projectPath), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sourceQueueId, itemIds: selectedItemIds })
});
const result = await response.json();
if (result.success) {
showNotification(`Split ${result.splitItemCount} items into new queue ${result.newQueueId}`, 'success');
hideSplitQueueModal();
queueData.expandedQueueId = null;
await Promise.all([loadQueueData(), loadAllQueues()]);
renderIssueView();
} else {
showNotification(result.error || 'Failed to split queue', 'error');
}
} catch (err) {
console.error('Failed to split queue:', err);
showNotification('Failed to split queue', 'error');
}
}
// ========== Legacy Queue Render (for backward compatibility) ==========
function renderLegacyQueueSection() {
const queue = issueData.queue;

View File

@@ -1024,7 +1024,9 @@ async function loadAndRenderMultiCliSummaryTab(session, contentArea) {
const response = await fetch(`/api/session-detail?path=${encodeURIComponent(session.path)}&type=summary`);
if (response.ok) {
const data = await response.json();
contentArea.innerHTML = renderMultiCliSummaryContent(data.summary, session);
// Support both summaries (from .summaries/) and summary (from plan.json)
const summaryText = data.summary || (data.summaries?.length ? data.summaries[0].content : null);
contentArea.innerHTML = renderMultiCliSummaryContent(summaryText, session);
initCollapsibleSections(contentArea);
if (typeof lucide !== 'undefined') lucide.createIcons();
return;
@@ -3135,16 +3137,38 @@ async function loadAndRenderLiteSummaryTab(session, contentArea) {
const response = await fetch(`/api/session-detail?path=${encodeURIComponent(session.path)}&type=summary`);
if (response.ok) {
const data = await response.json();
contentArea.innerHTML = renderSummaryContent(data.summaries);
return;
// Prioritize .summaries/ directory content
if (data.summaries?.length) {
contentArea.innerHTML = renderSummaryContent(data.summaries);
if (typeof lucide !== 'undefined') lucide.createIcons();
return;
}
// Fallback to plan.json summary field
if (data.summary) {
contentArea.innerHTML = renderSummaryContent([{ name: 'Summary', content: data.summary }]);
if (typeof lucide !== 'undefined') lucide.createIcons();
return;
}
}
}
// Fallback
// Fallback: try to get summary from session object (plan.summary or synthesis.convergence.summary)
const plan = session.plan || {};
const synthesis = session.latestSynthesis || session.discussionTopic || {};
const summaryText = plan.summary || synthesis.convergence?.summary;
if (summaryText) {
contentArea.innerHTML = renderSummaryContent([{ name: 'Summary', content: summaryText }]);
if (typeof lucide !== 'undefined') lucide.createIcons();
return;
}
// No summary available
contentArea.innerHTML = `
<div class="tab-empty-state">
<div class="empty-icon"><i data-lucide="file-text" class="w-12 h-12"></i></div>
<div class="empty-title">No Summaries</div>
<div class="empty-text">No summaries found in .summaries/</div>
<div class="empty-title">${t('empty.noSummary') || 'No Summary'}</div>
<div class="empty-text">${t('empty.noSummaryText') || 'No summary available for this session.'}</div>
</div>
`;
if (typeof lucide !== 'undefined') lucide.createIcons();

View File

@@ -364,6 +364,16 @@ const ParamsSchema = z.object({
parentExecutionId: z.string().optional(), // Parent execution ID for fork/retry scenarios
stream: z.boolean().default(false), // false = cache full output (default), true = stream output via callback
outputFormat: z.enum(['text', 'json-lines']).optional().default('json-lines'), // Output parsing format (default: json-lines for type badges)
// Codex review options
uncommitted: z.boolean().optional(), // Review uncommitted changes (default for review mode)
base: z.string().optional(), // Review changes against base branch
commit: z.string().optional(), // Review changes from specific commit
title: z.string().optional(), // Optional title for review summary
// Rules env vars (PROTO, TMPL) - will be passed to subprocess environment
rulesEnv: z.object({
PROTO: z.string().optional(),
TMPL: z.string().optional(),
}).optional(),
});
type Params = z.infer<typeof ParamsSchema>;
@@ -388,7 +398,7 @@ async function executeCliTool(
throw new Error(`Invalid params: ${parsed.error.message}`);
}
const { tool, prompt, mode, format, model, cd, includeDirs, resume, id: customId, noNative, category, parentExecutionId, outputFormat } = parsed.data;
const { tool, prompt, mode, format, model, cd, includeDirs, resume, id: customId, noNative, category, parentExecutionId, outputFormat, uncommitted, base, commit, title, rulesEnv } = parsed.data;
// Validate and determine working directory early (needed for conversation lookup)
let workingDir: string;
@@ -786,7 +796,8 @@ async function executeCliTool(
model: effectiveModel,
dir: cd,
include: includeDirs,
nativeResume: nativeResumeConfig
nativeResume: nativeResumeConfig,
reviewOptions: mode === 'review' ? { uncommitted, base, commit, title } : undefined
});
// Create output parser and IR storage
@@ -823,9 +834,11 @@ async function executeCliTool(
}
// Merge custom env with process.env (custom env takes precedence)
// Also include rulesEnv for $PROTO and $TMPL template variables
const spawnEnv = {
...process.env,
...customEnv
...customEnv,
...(rulesEnv || {})
};
debugLog('SPAWN', `Spawning process`, {

View File

@@ -159,8 +159,15 @@ export function buildCommand(params: {
nativeResume?: NativeResumeConfig;
/** Claude CLI settings file path (for --settings parameter) */
settingsFile?: string;
/** Codex review options */
reviewOptions?: {
uncommitted?: boolean;
base?: string;
commit?: string;
title?: string;
};
}): { command: string; args: string[]; useStdin: boolean } {
const { tool, prompt, mode = 'analysis', model, dir, include, nativeResume, settingsFile } = params;
const { tool, prompt, mode = 'analysis', model, dir, include, nativeResume, settingsFile, reviewOptions } = params;
debugLog('BUILD_CMD', `Building command for tool: ${tool}`, {
mode,
@@ -227,10 +234,25 @@ export function buildCommand(params: {
// codex review mode: non-interactive code review
// Format: codex review [OPTIONS] [PROMPT]
args.push('review');
// Default to --uncommitted if no specific review target in prompt
args.push('--uncommitted');
// Review target: --uncommitted (default), --base <branch>, or --commit <sha>
if (reviewOptions?.base) {
args.push('--base', reviewOptions.base);
} else if (reviewOptions?.commit) {
args.push('--commit', reviewOptions.commit);
} else {
// Default to --uncommitted if no specific target
args.push('--uncommitted');
}
// Optional title for review summary
if (reviewOptions?.title) {
args.push('--title', reviewOptions.title);
}
if (model) {
args.push('-m', model);
// codex review uses -c key=value for config override, not -m
args.push('-c', `model=${model}`);
}
// codex review uses positional prompt argument, not stdin
useStdin = false;

View File

@@ -0,0 +1,303 @@
/**
* Template Discovery Module
*
* Provides auto-discovery and loading of CLI templates from
* ~/.claude/workflows/cli-templates/
*
* Features:
* - Scan prompts/ directory (flat structure with category-function.txt naming)
* - Match template names (e.g., "analysis-review-architecture" or just "review-architecture")
* - Load protocol files based on mode (analysis/write)
* - Cache template content for performance
*/
import { readdirSync, readFileSync, existsSync } from 'fs';
import { join, basename, extname } from 'path';
import { homedir } from 'os';
// ============================================================================
// Types
// ============================================================================
export interface TemplateMeta {
name: string; // Full filename without extension (e.g., "analysis-review-architecture")
path: string; // Full absolute path
category: string; // Category from filename (e.g., "analysis")
shortName: string; // Name without category prefix (e.g., "review-architecture")
}
export interface TemplateIndex {
templates: Map<string, TemplateMeta>; // name -> meta (full name match)
byShortName: Map<string, TemplateMeta>; // shortName -> meta (for fuzzy match)
categories: Map<string, string[]>; // category -> template names
lastScan: number;
}
// ============================================================================
// Constants
// ============================================================================
const TEMPLATES_BASE_DIR = join(homedir(), '.claude', 'workflows', 'cli-templates');
const PROMPTS_DIR = join(TEMPLATES_BASE_DIR, 'prompts');
const PROTOCOLS_DIR = join(TEMPLATES_BASE_DIR, 'protocols');
const PROTOCOL_FILES: Record<string, string> = {
analysis: 'analysis-protocol.md',
write: 'write-protocol.md',
};
// Cache
let templateIndex: TemplateIndex | null = null;
const contentCache: Map<string, string> = new Map();
// ============================================================================
// Public API
// ============================================================================
/**
* Get the base templates directory path
*/
export function getTemplatesDir(): string {
return TEMPLATES_BASE_DIR;
}
/**
* Get the prompts directory path
*/
export function getPromptsDir(): string {
return PROMPTS_DIR;
}
/**
* Get the protocols directory path
*/
export function getProtocolsDir(): string {
return PROTOCOLS_DIR;
}
/**
* Scan templates directory and build index
* Flat structure: prompts/category-function.txt
* Results are cached for performance
*/
export function scanTemplates(forceRescan = false): TemplateIndex {
if (templateIndex && !forceRescan) {
return templateIndex;
}
const templates = new Map<string, TemplateMeta>();
const byShortName = new Map<string, TemplateMeta>();
const categories = new Map<string, string[]>();
if (!existsSync(PROMPTS_DIR)) {
console.warn(`[template-discovery] Prompts directory not found: ${PROMPTS_DIR}`);
templateIndex = { templates, byShortName, categories, lastScan: Date.now() };
return templateIndex;
}
// Scan all files directly in prompts/ (flat structure)
const files = readdirSync(PROMPTS_DIR).filter(file => {
const ext = extname(file).toLowerCase();
return ext === '.txt' || ext === '.md';
});
for (const file of files) {
const name = basename(file, extname(file)); // e.g., "analysis-review-architecture"
const fullPath = join(PROMPTS_DIR, file);
// Extract category from filename (first segment before -)
const dashIndex = name.indexOf('-');
const category = dashIndex > 0 ? name.substring(0, dashIndex) : 'other';
const shortName = dashIndex > 0 ? name.substring(dashIndex + 1) : name;
const meta: TemplateMeta = {
name,
path: fullPath,
category,
shortName,
};
// Index by full name
templates.set(name, meta);
// Index by short name (for fuzzy match)
// If duplicate shortName exists, prefer keeping first one
if (!byShortName.has(shortName)) {
byShortName.set(shortName, meta);
}
// Group by category
if (!categories.has(category)) {
categories.set(category, []);
}
categories.get(category)!.push(name);
}
templateIndex = { templates, byShortName, categories, lastScan: Date.now() };
return templateIndex;
}
/**
* Find a template by name
*
* @param nameOrShort - Full template name (e.g., "analysis-review-architecture")
* or short name (e.g., "review-architecture")
* @returns Full path to template file, or null if not found
*/
export function findTemplate(nameOrShort: string): string | null {
const index = scanTemplates();
// Try exact full name match first
if (index.templates.has(nameOrShort)) {
return index.templates.get(nameOrShort)!.path;
}
// Try with .txt extension removed
const nameWithoutExt = nameOrShort.replace(/\.(txt|md)$/i, '');
if (index.templates.has(nameWithoutExt)) {
return index.templates.get(nameWithoutExt)!.path;
}
// Try short name match (without category prefix)
if (index.byShortName.has(nameOrShort)) {
return index.byShortName.get(nameOrShort)!.path;
}
// Try short name without extension
if (index.byShortName.has(nameWithoutExt)) {
return index.byShortName.get(nameWithoutExt)!.path;
}
return null;
}
/**
* Load protocol content based on mode
*
* @param mode - Execution mode: "analysis" or "write"
* @returns Protocol file content, or empty string if not found
*/
export function loadProtocol(mode: string): string {
const protocolFile = PROTOCOL_FILES[mode];
if (!protocolFile) {
console.warn(`[template-discovery] No protocol defined for mode: ${mode}`);
return '';
}
const protocolPath = join(PROTOCOLS_DIR, protocolFile);
// Check cache
if (contentCache.has(protocolPath)) {
return contentCache.get(protocolPath)!;
}
if (!existsSync(protocolPath)) {
console.warn(`[template-discovery] Protocol file not found: ${protocolPath}`);
return '';
}
try {
const content = readFileSync(protocolPath, 'utf8');
contentCache.set(protocolPath, content);
return content;
} catch (error) {
console.error(`[template-discovery] Failed to read protocol: ${protocolPath}`, error);
return '';
}
}
/**
* Load template content by name or path
*
* @param nameOrPath - Template name or relative path
* @returns Template file content
* @throws Error if template not found
*/
export function loadTemplate(nameOrPath: string): string {
const templatePath = findTemplate(nameOrPath);
if (!templatePath) {
// List available templates for helpful error message
const index = scanTemplates();
const available = Array.from(index.templates.keys()).slice(0, 10).join(', ');
throw new Error(
`Template not found: "${nameOrPath}"\n` +
`Available templates (first 10): ${available}...\n` +
`Use 'ccw cli templates' to list all available templates.`
);
}
// Check cache
if (contentCache.has(templatePath)) {
return contentCache.get(templatePath)!;
}
try {
const content = readFileSync(templatePath, 'utf8');
contentCache.set(templatePath, content);
return content;
} catch (error) {
throw new Error(`Failed to read template: ${templatePath}: ${error}`);
}
}
/**
* Build rules content from protocol and template
*
* @param mode - Execution mode for protocol selection
* @param templateName - Template name or path (optional)
* @param includeProtocol - Whether to include protocol (default: true)
* @returns Combined rules content
*/
export function buildRulesContent(
mode: string,
templateName?: string,
includeProtocol = true
): string {
const parts: string[] = [];
// Load protocol if requested
if (includeProtocol) {
const protocol = loadProtocol(mode);
if (protocol) {
parts.push(protocol);
}
}
// Load template if specified
if (templateName) {
const template = loadTemplate(templateName);
if (template) {
parts.push(template);
}
}
return parts.join('\n\n');
}
/**
* List all available templates
*
* @returns Object with categories and their templates
*/
export function listTemplates(): Record<string, TemplateMeta[]> {
const index = scanTemplates();
const result: Record<string, TemplateMeta[]> = {};
for (const [category, names] of index.categories) {
result[category] = names.map(name => {
const meta = index.templates.get(name);
return meta || { name, path: '', category, shortName: '' };
});
}
return result;
}
/**
* Clear template cache (useful for testing or after template updates)
*/
export function clearCache(): void {
templateIndex = null;
contentCache.clear();
}