Skip to content

Commit ea2638d

Browse files
author
catlog22
committed
feat: add backup functionality and CCW content management for installation and uninstallation processes
1 parent 5be30e4 commit ea2638d

File tree

3 files changed

+274
-118
lines changed

3 files changed

+274
-118
lines changed

.claude/commands/workflow-tune.md

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,13 @@ for (const [stepIdx, step] of stepsNeedTask.entries()) {
121121
// - 若 upstreamFlags 非空,在任务中传递(如 "--type draft-section")
122122
// - 命令参数不含单个 task ID,用 --all 或 --type 或无参数
123123
// - 验收标准必须含全量覆盖率("产出文件数 = plan 中 task 数")
124+
// - ★ TYPE-AWARE CRITERIA: 从上游 plan 产物(plan.json 等)中提取
125+
// task type 分布,为每种 type 生成对应的验收标准:
126+
// 文本类(draft-section/writing/review) → "对应 .md 文件数 >= N"
127+
// 代码类(figure/generate, experiment/*) → "代码文件(.py/.svg) 数 >= N"
128+
// 数据类(data/*, assembly/*) → "数据/组装文件数 >= N"
129+
// 这确保非文本类产出(图片代码、实验脚本、组装产物)不被静默跳过。
130+
// 若上游 plan 不可读,退回通用全量覆盖标准。
124131
//
125132
// ★ hasUpstreamScope=false 时:
126133
// - 按命令类型分配子任务: plan→架构设计, implement→功能实现, analyze→分析, test→测试
@@ -371,16 +378,31 @@ const artifactSummary = maxLines === 0
371378
catch { return `--- ${a.path} --- [unreadable]`; }
372379
}).join('\n\n');
373380
381+
// ★ Detect upstream plan artifact for type-aware evaluation
382+
// Scans sandbox for plan.json/plan.yaml → extracts task type distribution
383+
// Enables gemini to evaluate whether ALL task types produced outputs
384+
const planTypeContext = (() => {
385+
try {
386+
const planFiles = Glob(`${state.sandbox_dir}/**/plan.json`);
387+
if (!planFiles.length) return '';
388+
const plan = JSON.parse(Read(planFiles[0]));
389+
if (!plan.tasks?.length) return '';
390+
const typeDist = {};
391+
plan.tasks.forEach(t => { typeDist[t.type] = (typeDist[t.type] || 0) + 1; });
392+
return `\nPLAN TASK TYPES: ${JSON.stringify(typeDist)}\nNOTE: Evaluate output completeness per task type. Missing types (e.g. plan has figure tasks but no .py/.png/.svg outputs) are critical gaps.`;
393+
} catch { return ''; }
394+
})();
395+
374396
const analysisPrompt = `PURPOSE: Evaluate step "${step.name}" (${stepIdx + 1}/${state.steps.length}).
375397
WORKFLOW: ${state.workflow_name} — ${state.project_scenario}
376398
COMMAND: ${step.command}
377399
TEST TASK: ${step.test_task || 'N/A'}
378400
ACCEPTANCE CRITERIA: ${(step.acceptance_criteria || []).join('; ') || 'N/A'}
379-
EXECUTION: ${step.execution.duration_ms}ms | ${manifest.artifacts.length} artifacts
401+
EXECUTION: ${step.execution.duration_ms}ms | ${manifest.artifacts.length} artifacts${planTypeContext}
380402
ARTIFACTS:
381403
${artifactSummary}
382404
383-
OUTPUT (strict JSON): { "quality_score": <0-100>, "requirement_match": { "pass": <bool>, "criteria_met": [], "criteria_missed": [] }, "execution_assessment": { "success": <bool>, "completeness": "" }, "artifact_assessment": { "count": <n>, "quality": "", "key_outputs": [], "missing_outputs": [] }, "issues": [{ "severity": "critical|high|medium|low", "description": "", "suggestion": "" }], "optimization_opportunities": [{ "area": "", "impact": "high|medium|low", "description": "" }], "step_summary": "" }`;
405+
OUTPUT (strict JSON): { "quality_score": <0-100>, "requirement_match": { "pass": <bool>, "criteria_met": [], "criteria_missed": [] }, "execution_assessment": { "success": <bool>, "completeness": "" }, "artifact_assessment": { "count": <n>, "quality": "", "key_outputs": [], "missing_outputs": [] }, "type_coverage": { "plan_types": {}, "output_types": {}, "missing": [] }, "issues": [{ "severity": "critical|high|medium|low", "description": "", "suggestion": "" }], "optimization_opportunities": [{ "area": "", "impact": "high|medium|low", "description": "" }], "step_summary": "" }`;
384406
385407
let cmd = `ccw cli -p ${escapeForShell(analysisPrompt)} --tool gemini --mode analysis --rule analysis-review-code-quality`;
386408
if (state.gemini_session_id) cmd += ` --resume ${state.gemini_session_id}`;
@@ -425,7 +447,8 @@ STEP ANALYSES:
425447
${stepAnalyses}
426448
427449
Evaluate: cross-step coherence, handoff quality, bottlenecks, redundancy.
428-
OUTPUT (strict JSON): { "workflow_score": <0-100>, "coherence": { "score": <0-100>, "assessment": "", "gaps": [] }, "bottlenecks": [{ "step": "", "issue": "", "suggestion": "" }], "per_step_improvements": [{ "step": "", "priority": "high|medium|low", "action": "" }], "workflow_improvements": [{ "area": "", "impact": "high|medium|low", "description": "" }], "summary": "" }`;
450+
TYPE COVERAGE: If any step involved plan→execute, check whether ALL task types in the plan produced corresponding outputs. Missing types (e.g. plan has figure/code tasks but sandbox has only .md files) reduce workflow_score by 10 per missing type.
451+
OUTPUT (strict JSON): { "workflow_score": <0-100>, "coherence": { "score": <0-100>, "assessment": "", "gaps": [] }, "type_coverage": { "types_in_plan": [], "types_with_output": [], "missing_types": [], "coverage_rate": "<pct>" }, "bottlenecks": [{ "step": "", "issue": "", "suggestion": "" }], "per_step_improvements": [{ "step": "", "priority": "high|medium|low", "action": "" }], "workflow_improvements": [{ "area": "", "impact": "high|medium|low", "description": "" }], "summary": "" }`;
429452
430453
let cmd = `ccw cli -p ${escapeForShell(synthesisPrompt)} --tool gemini --mode analysis --rule analysis-review-architecture`;
431454
if (state.gemini_session_id) cmd += ` --resume ${state.gemini_session_id}`;

ccw/src/commands/install.ts

Lines changed: 174 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,9 @@ const GLOBAL_FILES = ['CLAUDE.CCW.md'];
3838
// Files that should be excluded from cleanup (user-specific settings)
3939
const EXCLUDED_FILES = ['settings.json', 'settings.local.json', 'CLAUDE.md'];
4040

41+
// CCW marker in CLAUDE.md for tracking ccw-added content
42+
const CCW_REFERENCE_LINE = '- **CCW Instructions**: @~/.claude/CLAUDE.CCW.md';
43+
4144
interface InstallOptions {
4245
mode?: string;
4346
path?: string;
@@ -189,6 +192,112 @@ function restoreDisabledState(
189192
return { skillsRestored, commandsRestored };
190193
}
191194

195+
/**
196+
* Backup existing files that will be replaced during installation
197+
* Works without manifest - scans target directories for existing content
198+
* @param installPath - Target installation path
199+
* @param availableDirs - Directories that will be installed
200+
* @param globalPath - Global home path (for global subdirs in Path mode)
201+
* @returns Backup directory path or null if nothing to backup
202+
*/
203+
async function backupBeforeInstall(
204+
installPath: string,
205+
availableDirs: string[],
206+
globalPath?: string
207+
): Promise<string | null> {
208+
const spinner = createSpinner('Creating pre-install backup...').start();
209+
210+
try {
211+
const timestamp = new Date().toISOString().replace(/[-:]/g, '').replace('T', '-').split('.')[0];
212+
const backupDir = join(installPath, `.ccw-backup-${timestamp}`);
213+
let backedUp = 0;
214+
215+
// Backup directories that will be overwritten
216+
for (const dir of availableDirs) {
217+
const targetDir = join(installPath, dir);
218+
if (!existsSync(targetDir)) continue;
219+
220+
const backupTarget = join(backupDir, dir);
221+
const result = await backupDirectoryRecursive(targetDir, backupTarget, spinner);
222+
backedUp += result;
223+
}
224+
225+
// Backup global subdirs if Path mode
226+
if (globalPath) {
227+
for (const subdir of GLOBAL_SUBDIRS) {
228+
const globalSubdir = join(globalPath, '.claude', subdir);
229+
if (!existsSync(globalSubdir)) continue;
230+
231+
const backupTarget = join(backupDir, '.claude-global', subdir);
232+
const result = await backupDirectoryRecursive(globalSubdir, backupTarget, spinner);
233+
backedUp += result;
234+
}
235+
236+
// Backup global CLAUDE.CCW.md
237+
const globalCcwMd = join(globalPath, '.claude', 'CLAUDE.CCW.md');
238+
if (existsSync(globalCcwMd)) {
239+
const backupTarget = join(backupDir, '.claude-global', 'CLAUDE.CCW.md');
240+
const backupFileDir = dirname(backupTarget);
241+
if (!existsSync(backupFileDir)) mkdirSync(backupFileDir, { recursive: true });
242+
copyFileSync(globalCcwMd, backupTarget);
243+
backedUp++;
244+
}
245+
}
246+
247+
if (backedUp > 0) {
248+
spinner.succeed(`Pre-install backup: ${backupDir} (${backedUp} files)`);
249+
return backupDir;
250+
} else {
251+
spinner.info('No existing files to backup');
252+
// Remove empty backup dir
253+
try { rmdirSync(backupDir); } catch { /* ignore */ }
254+
return null;
255+
}
256+
} catch (err) {
257+
const errMsg = err as Error;
258+
spinner.warn(`Backup warning: ${errMsg.message}`);
259+
return null;
260+
}
261+
}
262+
263+
/**
264+
* Recursively backup a directory
265+
*/
266+
async function backupDirectoryRecursive(
267+
src: string,
268+
dest: string,
269+
spinner: Ora
270+
): Promise<number> {
271+
let count = 0;
272+
273+
if (!existsSync(src)) return count;
274+
275+
if (!existsSync(dest)) {
276+
mkdirSync(dest, { recursive: true });
277+
}
278+
279+
const entries = readdirSync(src);
280+
for (const entry of entries) {
281+
const srcPath = join(src, entry);
282+
const destPath = join(dest, entry);
283+
const stat = statSync(srcPath);
284+
285+
if (stat.isDirectory()) {
286+
count += await backupDirectoryRecursive(srcPath, destPath, spinner);
287+
} else {
288+
try {
289+
spinner.text = `Backing up: ${entry}`;
290+
copyFileSync(srcPath, destPath);
291+
count++;
292+
} catch {
293+
// Ignore individual file errors
294+
}
295+
}
296+
}
297+
298+
return count;
299+
}
300+
192301
// Get package root directory (ccw/src/commands -> ccw)
193302
function getPackageRoot(): string {
194303
return join(__dirname, '..', '..');
@@ -308,33 +417,61 @@ export async function installCommand(options: InstallOptions): Promise<void> {
308417
console.log('');
309418
info(`Found ${availableDirs.length} directories to install: ${availableDirs.join(', ')}`);
310419

311-
// Show what will be installed including .codex subdirectories
420+
// Interactive .codex subdirectory selection
421+
let codexExcludeDirs: string[] = [];
312422
if (availableDirs.includes('.codex')) {
313423
const codexPath = join(sourceDir, '.codex');
424+
const codexSubdirs: Array<{ name: string; count: number; label: string }> = [];
314425

315-
// Show prompts info
426+
// Scan available codex subdirectories
316427
const promptsPath = join(codexPath, 'prompts');
317428
if (existsSync(promptsPath)) {
318429
const promptFiles = readdirSync(promptsPath, { recursive: true }).filter(f =>
319430
statSync(join(promptsPath, f.toString())).isFile()
320431
);
321-
info(` └─ .codex/prompts: ${promptFiles.length} files`);
432+
codexSubdirs.push({ name: 'prompts', count: promptFiles.length, label: 'files' });
322433
}
323434

324-
// Show agents info
325435
const agentsPath = join(codexPath, 'agents');
326436
if (existsSync(agentsPath)) {
327437
const agentFiles = readdirSync(agentsPath).filter(f => f.endsWith('.md'));
328-
info(` └─ .codex/agents: ${agentFiles.length} agent definitions`);
438+
codexSubdirs.push({ name: 'agents', count: agentFiles.length, label: 'agent definitions' });
329439
}
330440

331-
// Show skills info
332441
const skillsPath = join(codexPath, 'skills');
333442
if (existsSync(skillsPath)) {
334443
const skillDirs = readdirSync(skillsPath).filter(f =>
335444
statSync(join(skillsPath, f)).isDirectory()
336445
);
337-
info(` └─ .codex/skills: ${skillDirs.length} skills`);
446+
codexSubdirs.push({ name: 'skills', count: skillDirs.length, label: 'skills' });
447+
}
448+
449+
if (codexSubdirs.length > 0) {
450+
info('Codex components available:');
451+
for (const sub of codexSubdirs) {
452+
info(` └─ .codex/${sub.name}: ${sub.count} ${sub.label}`);
453+
}
454+
455+
// Let user select which codex subdirs to install
456+
const { selectedCodexDirs } = await inquirer.prompt([{
457+
type: 'checkbox',
458+
name: 'selectedCodexDirs',
459+
message: 'Select .codex components to install:',
460+
choices: codexSubdirs.map(sub => ({
461+
name: ` ${chalk.cyan(sub.name)}${sub.count} ${sub.label}`,
462+
value: sub.name,
463+
checked: sub.name !== 'agents', // agents unchecked by default
464+
})),
465+
}]);
466+
467+
// Build exclude list from unselected dirs
468+
codexExcludeDirs = codexSubdirs
469+
.map(s => s.name)
470+
.filter(name => !(selectedCodexDirs as string[]).includes(name));
471+
472+
if (codexExcludeDirs.length > 0) {
473+
info(`Skipping .codex components: ${codexExcludeDirs.join(', ')}`);
474+
}
338475
}
339476
}
340477

@@ -404,28 +541,37 @@ export async function installCommand(options: InstallOptions): Promise<void> {
404541

405542
divider();
406543

407-
// Check for existing installation manifest
408-
const existingManifest = findManifest(installPath, mode);
409-
let cleanStats = { removed: 0, skipped: 0 };
410-
411-
if (existingManifest) {
412-
// Has manifest - clean based on manifest records
413-
warning('Existing installation found at this location');
414-
info(` Files in manifest: ${existingManifest.files?.length || 0}`);
415-
info(` Installed: ${new Date(existingManifest.installation_date).toLocaleDateString()}`);
544+
// Always backup existing content before overwriting
545+
const existingDirsToBackup = availableDirs.filter(dir => existsSync(join(installPath, dir)));
546+
const globalPathForBackup = mode === 'Path' ? homedir() : undefined;
416547

548+
if (existingDirsToBackup.length > 0 || (globalPathForBackup && existsSync(join(globalPathForBackup, '.claude')))) {
417549
if (!options.force) {
418-
const { backup } = await inquirer.prompt([{
550+
const { doBackup } = await inquirer.prompt([{
419551
type: 'confirm',
420-
name: 'backup',
421-
message: 'Create backup before reinstalling?',
552+
name: 'doBackup',
553+
message: 'Backup existing files before installing? (recommended)',
422554
default: true
423555
}]);
424556

425-
if (backup) {
426-
await createBackup(existingManifest);
557+
if (doBackup) {
558+
await backupBeforeInstall(installPath, existingDirsToBackup, globalPathForBackup);
427559
}
560+
} else {
561+
// Force mode: always backup silently
562+
await backupBeforeInstall(installPath, existingDirsToBackup, globalPathForBackup);
428563
}
564+
}
565+
566+
// Check for existing installation manifest for cleanup
567+
const existingManifest = findManifest(installPath, mode);
568+
let cleanStats = { removed: 0, skipped: 0 };
569+
570+
if (existingManifest) {
571+
// Has manifest - clean based on manifest records
572+
warning('Existing installation found at this location');
573+
info(` Files in manifest: ${existingManifest.files?.length || 0}`);
574+
info(` Installed: ${new Date(existingManifest.installation_date).toLocaleDateString()}`);
429575

430576
// Clean based on manifest records
431577
console.log('');
@@ -511,7 +657,13 @@ export async function installCommand(options: InstallOptions): Promise<void> {
511657
spinner.text = `Installing ${dir}...`;
512658

513659
// For Path mode on .claude, exclude global subdirs (they're already installed to global)
514-
const excludeDirs = (mode === 'Path' && dir === '.claude') ? GLOBAL_SUBDIRS : [];
660+
// For .codex, exclude user-deselected subdirs (e.g. agents)
661+
let excludeDirs: string[] = [];
662+
if (mode === 'Path' && dir === '.claude') {
663+
excludeDirs = GLOBAL_SUBDIRS;
664+
} else if (dir === '.codex') {
665+
excludeDirs = codexExcludeDirs;
666+
}
515667
const { files, directories } = await copyDirectory(srcPath, destPath, manifest, excludeDirs);
516668
totalFiles += files;
517669
totalDirs += directories;

0 commit comments

Comments
 (0)