mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-13 02:41:50 +08:00
- Introduced the `/workflow:clean` command for intelligent code cleanup. - Implemented mainline detection to identify active development branches and core modules. - Added drift analysis to discover stale sessions, abandoned documents, and dead code. - Included safe execution features with staged deletion and confirmation. - Documented usage, execution process, and implementation details in `clean.md`.
1006 lines
30 KiB
Markdown
1006 lines
30 KiB
Markdown
---
|
|
name: clean
|
|
description: Intelligent code cleanup with mainline detection, stale artifact discovery, and safe execution (Codex version)
|
|
argument-hint: "[--dry-run] [\"focus area\"]"
|
|
---
|
|
|
|
# Clean Command (/workflow:clean) - Codex Version
|
|
|
|
## Overview
|
|
|
|
Intelligent cleanup command that explores the codebase to identify the development mainline, discovers artifacts that have drifted from it, and safely removes stale sessions, abandoned documents, and dead code.
|
|
|
|
**Core capabilities:**
|
|
- Mainline detection: Identify active development branches and core modules
|
|
- Drift analysis: Find sessions, documents, and code that deviate from mainline
|
|
- Intelligent discovery: cli-explore-agent based artifact scanning (using Codex subagent)
|
|
- Safe execution: Staged deletion with confirmation and recovery capability
|
|
|
|
## Usage
|
|
|
|
```bash
|
|
/workflow:clean # Full intelligent cleanup (explore -> analyze -> confirm -> execute)
|
|
/workflow:clean --dry-run # Explore and analyze only, no execution
|
|
/workflow:clean "auth module" # Focus cleanup on specific area
|
|
```
|
|
|
|
## Execution Process
|
|
|
|
```
|
|
Phase 0: Initialization
|
|
├─ Parse command arguments
|
|
├─ Define utility functions
|
|
└─ Setup session folder
|
|
|
|
Phase 1: Mainline Detection
|
|
├─ Analyze git history for development trends
|
|
├─ Identify core modules (high commit frequency)
|
|
├─ Map active vs stale branches
|
|
└─ Build mainline profile
|
|
|
|
Phase 2: Drift Discovery (Codex cli-explore-agent)
|
|
├─ spawn_agent with role path
|
|
├─ Scan workflow sessions for orphaned artifacts
|
|
├─ Identify documents drifted from mainline
|
|
├─ Detect dead code and unused exports
|
|
├─ wait() for results with timeout handling
|
|
└─ Generate cleanup manifest
|
|
|
|
Phase 3: Confirmation
|
|
├─ Validate manifest schema
|
|
├─ Display cleanup summary by category
|
|
├─ Show impact analysis (files, size, risk)
|
|
└─ AskUserQuestion: Select categories to clean
|
|
|
|
Phase 4: Execution (unless --dry-run)
|
|
├─ Validate paths (security check)
|
|
├─ Stage deletion (move to .trash first)
|
|
├─ Update manifests and indexes
|
|
├─ Permanent deletion after verification
|
|
└─ Report results
|
|
```
|
|
|
|
## Implementation
|
|
|
|
### Phase 0: Initialization
|
|
|
|
**Argument Parsing and Utility Functions**:
|
|
```javascript
|
|
// ==================== Utility Functions ====================
|
|
|
|
const getUtc8ISOString = () => new Date(Date.now() + 8 * 60 * 60 * 1000).toISOString()
|
|
|
|
/**
|
|
* Parse command arguments
|
|
* @param {string} args - Raw command arguments
|
|
* @returns {{ flags: string[], focusArea: string | null }}
|
|
*/
|
|
function parseArguments(args) {
|
|
const parts = (args || '').trim().split(/\s+/)
|
|
const flags = parts.filter(p => p.startsWith('--'))
|
|
const nonFlags = parts.filter(p => !p.startsWith('--'))
|
|
const focusArea = nonFlags.length > 0 ? nonFlags.join(' ').replace(/^["']|["']$/g, '') : null
|
|
return { flags, focusArea }
|
|
}
|
|
|
|
/**
|
|
* Check if file exists
|
|
* @param {string} filePath - Path to check
|
|
* @returns {boolean}
|
|
*/
|
|
function fileExists(filePath) {
|
|
try {
|
|
const result = Bash({ command: `test -f "${filePath}" && echo "exists" || echo "not_found"` })
|
|
return result.includes('exists')
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if directory exists
|
|
* @param {string} dirPath - Path to check
|
|
* @returns {boolean}
|
|
*/
|
|
function dirExists(dirPath) {
|
|
try {
|
|
const result = Bash({ command: `test -d "${dirPath}" && echo "exists" || echo "not_found"` })
|
|
return result.includes('exists')
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get risk summary for a category from manifest
|
|
* @param {object} manifest - The cleanup manifest
|
|
* @param {string} category - Category name ('sessions' | 'documents' | 'code')
|
|
* @returns {string} Risk summary string
|
|
*/
|
|
function getRiskSummary(manifest, category) {
|
|
const categoryMap = {
|
|
'sessions': 'stale_sessions',
|
|
'documents': 'drifted_documents',
|
|
'code': 'dead_code'
|
|
}
|
|
const items = manifest.discoveries[categoryMap[category]] || []
|
|
const riskCounts = { low: 0, medium: 0, high: 0 }
|
|
items.forEach(item => {
|
|
if (riskCounts[item.risk] !== undefined) {
|
|
riskCounts[item.risk]++
|
|
}
|
|
})
|
|
const parts = []
|
|
if (riskCounts.high > 0) parts.push(`${riskCounts.high}H`)
|
|
if (riskCounts.medium > 0) parts.push(`${riskCounts.medium}M`)
|
|
if (riskCounts.low > 0) parts.push(`${riskCounts.low}L`)
|
|
return parts.length > 0 ? parts.join('/') : '-'
|
|
}
|
|
|
|
/**
|
|
* Validate path is safe for deletion (security check)
|
|
* @param {string} targetPath - Path to validate
|
|
* @param {string} projectRoot - Project root directory
|
|
* @returns {{ valid: boolean, reason?: string }}
|
|
*/
|
|
function validatePathForDeletion(targetPath, projectRoot) {
|
|
// Normalize paths
|
|
const normalizedTarget = targetPath.replace(/\\/g, '/').replace(/\/+/g, '/')
|
|
const normalizedRoot = projectRoot.replace(/\\/g, '/').replace(/\/+/g, '/')
|
|
|
|
// Check for path traversal attempts
|
|
if (normalizedTarget.includes('..')) {
|
|
return { valid: false, reason: 'Path contains traversal sequence (..)' }
|
|
}
|
|
|
|
// Check path is within project
|
|
if (!normalizedTarget.startsWith(normalizedRoot) && !normalizedTarget.startsWith('.')) {
|
|
// Allow relative paths starting with . (like .workflow/)
|
|
if (!normalizedTarget.startsWith('.workflow/') &&
|
|
!normalizedTarget.startsWith('.claude/') &&
|
|
!normalizedTarget.startsWith('src/')) {
|
|
return { valid: false, reason: 'Path is outside allowed directories' }
|
|
}
|
|
}
|
|
|
|
// Whitelist of allowed deletion prefixes
|
|
const allowedPrefixes = [
|
|
'.workflow/active/',
|
|
'.workflow/archives/',
|
|
'.workflow/.lite-plan/',
|
|
'.workflow/.debug/',
|
|
'.workflow/.scratchpad/',
|
|
'.workflow/.clean/',
|
|
'.claude/rules/tech/'
|
|
]
|
|
|
|
const isInAllowedDir = allowedPrefixes.some(prefix =>
|
|
normalizedTarget.startsWith(prefix) || normalizedTarget.includes('/' + prefix)
|
|
)
|
|
|
|
// For dead code, allow src/ paths but require extra validation
|
|
const isSourceCode = normalizedTarget.includes('/src/') || normalizedTarget.startsWith('src/')
|
|
|
|
if (!isInAllowedDir && !isSourceCode) {
|
|
return { valid: false, reason: 'Path not in allowed deletion directories' }
|
|
}
|
|
|
|
// Check for dangerous patterns
|
|
const dangerousPatterns = [
|
|
/^\/$/, /^\/home$/, /^\/usr$/, /^\/etc$/, /^\/var$/,
|
|
/^C:\\?$/i, /^C:\\Windows/i, /^C:\\Users$/i,
|
|
/node_modules/, /\.git$/
|
|
]
|
|
|
|
for (const pattern of dangerousPatterns) {
|
|
if (pattern.test(normalizedTarget)) {
|
|
return { valid: false, reason: 'Path matches dangerous pattern' }
|
|
}
|
|
}
|
|
|
|
return { valid: true }
|
|
}
|
|
|
|
/**
|
|
* Validate manifest schema
|
|
* @param {object} manifest - Manifest object to validate
|
|
* @returns {{ valid: boolean, errors: string[] }}
|
|
*/
|
|
function validateManifestSchema(manifest) {
|
|
const errors = []
|
|
|
|
if (!manifest || typeof manifest !== 'object') {
|
|
return { valid: false, errors: ['Manifest is not an object'] }
|
|
}
|
|
|
|
// Required top-level fields
|
|
if (!manifest.generated_at) errors.push('Missing generated_at')
|
|
if (!manifest.discoveries) errors.push('Missing discoveries')
|
|
if (!manifest.summary) errors.push('Missing summary')
|
|
|
|
// Validate discoveries structure
|
|
if (manifest.discoveries) {
|
|
const requiredCategories = ['stale_sessions', 'drifted_documents', 'dead_code']
|
|
for (const cat of requiredCategories) {
|
|
if (!Array.isArray(manifest.discoveries[cat])) {
|
|
errors.push(`discoveries.${cat} is not an array`)
|
|
}
|
|
}
|
|
|
|
// Validate each item has required fields
|
|
const allItems = [
|
|
...(manifest.discoveries.stale_sessions || []),
|
|
...(manifest.discoveries.drifted_documents || []),
|
|
...(manifest.discoveries.dead_code || [])
|
|
]
|
|
|
|
allItems.forEach((item, idx) => {
|
|
if (!item.path) errors.push(`Item ${idx} missing path`)
|
|
if (!item.risk) errors.push(`Item ${idx} missing risk`)
|
|
if (!['low', 'medium', 'high'].includes(item.risk)) {
|
|
errors.push(`Item ${idx} has invalid risk: ${item.risk}`)
|
|
}
|
|
})
|
|
}
|
|
|
|
return { valid: errors.length === 0, errors }
|
|
}
|
|
|
|
/**
|
|
* Safe JSON parse with error handling
|
|
* @param {string} content - JSON string to parse
|
|
* @param {*} defaultValue - Default value if parse fails
|
|
* @returns {*} Parsed object or default value
|
|
*/
|
|
function safeJsonParse(content, defaultValue = null) {
|
|
try {
|
|
return JSON.parse(content)
|
|
} catch (error) {
|
|
console.error(`JSON parse error: ${error.message}`)
|
|
return defaultValue
|
|
}
|
|
}
|
|
|
|
// ==================== Session Setup ====================
|
|
|
|
// Parse command arguments
|
|
const { flags, focusArea } = parseArguments(ARGUMENTS)
|
|
const isDryRun = flags.includes('--dry-run')
|
|
|
|
// Setup session
|
|
const dateStr = getUtc8ISOString().substring(0, 10)
|
|
const sessionId = `clean-${dateStr}`
|
|
const sessionFolder = `.workflow/.clean/${sessionId}`
|
|
const trashFolder = `${sessionFolder}/.trash`
|
|
const projectRoot = Bash({ command: 'pwd' }).trim()
|
|
|
|
// Create session directories
|
|
Bash({ command: `mkdir -p "${sessionFolder}"` })
|
|
Bash({ command: `mkdir -p "${trashFolder}"` })
|
|
|
|
console.log(`Session: ${sessionId}`)
|
|
console.log(`Focus: ${focusArea || 'entire project'}`)
|
|
console.log(`Mode: ${isDryRun ? 'dry-run (no changes)' : 'execute'}`)
|
|
```
|
|
|
|
---
|
|
|
|
### Phase 1: Mainline Detection
|
|
|
|
**Step 1.1: Git History Analysis**
|
|
```javascript
|
|
// Check if git repository exists
|
|
const isGitRepo = Bash({ command: 'git rev-parse --git-dir 2>/dev/null && echo "yes" || echo "no"' }).includes('yes')
|
|
|
|
let gitAnalysis = {
|
|
commitFrequency: [],
|
|
recentBranches: [],
|
|
recentFiles: []
|
|
}
|
|
|
|
if (isGitRepo) {
|
|
// Get commit frequency by directory (last 30 days)
|
|
const freqResult = Bash({
|
|
command: 'git log --since="30 days ago" --name-only --pretty=format: 2>/dev/null | grep -v "^$" | cut -d/ -f1-2 | sort | uniq -c | sort -rn | head -20'
|
|
})
|
|
|
|
// Get recent active branches
|
|
const branchResult = Bash({
|
|
command: 'git for-each-ref --sort=-committerdate refs/heads/ --format="%(refname:short) %(committerdate:relative)" 2>/dev/null | head -10'
|
|
})
|
|
|
|
// Get files with most recent changes
|
|
const filesResult = Bash({
|
|
command: 'git log --since="7 days ago" --name-only --pretty=format: 2>/dev/null | grep -v "^$" | sort | uniq -c | sort -rn | head -30'
|
|
})
|
|
|
|
gitAnalysis = {
|
|
commitFrequency: freqResult.trim().split('\n').filter(Boolean),
|
|
recentBranches: branchResult.trim().split('\n').filter(Boolean),
|
|
recentFiles: filesResult.trim().split('\n').filter(Boolean)
|
|
}
|
|
} else {
|
|
console.log('Warning: Not a git repository. Using file timestamps only.')
|
|
}
|
|
```
|
|
|
|
**Step 1.2: Build Mainline Profile**
|
|
```javascript
|
|
// Parse commit frequency to identify core modules
|
|
const coreModules = gitAnalysis.commitFrequency
|
|
.map(line => {
|
|
const match = line.trim().match(/^\s*(\d+)\s+(.+)$/)
|
|
return match ? { count: parseInt(match[1]), path: match[2] } : null
|
|
})
|
|
.filter(item => item && item.count >= 5)
|
|
.map(item => item.path)
|
|
|
|
const mainlineProfile = {
|
|
coreModules,
|
|
activeFiles: gitAnalysis.recentFiles.slice(0, 20),
|
|
activeBranches: gitAnalysis.recentBranches.map(b => b.split(' ')[0]),
|
|
staleThreshold: {
|
|
sessions: 7, // Days
|
|
branches: 30,
|
|
documents: 14
|
|
},
|
|
isGitRepo,
|
|
timestamp: getUtc8ISOString()
|
|
}
|
|
|
|
Write(`${sessionFolder}/mainline-profile.json`, JSON.stringify(mainlineProfile, null, 2))
|
|
console.log(`Mainline profile saved. Core modules: ${coreModules.length}`)
|
|
```
|
|
|
|
---
|
|
|
|
### Phase 2: Drift Discovery (Codex Subagent)
|
|
|
|
**Codex Implementation: spawn_agent + wait with proper lifecycle management**
|
|
|
|
```javascript
|
|
let exploreAgent = null
|
|
|
|
try {
|
|
// Step 1: Launch cli-explore-agent (role path passed, agent reads it)
|
|
exploreAgent = spawn_agent({
|
|
message: `
|
|
## TASK ASSIGNMENT
|
|
|
|
### MANDATORY FIRST STEPS (Agent Execute)
|
|
1. **Read role definition**: ~/.codex/agents/cli-explore-agent.md (MUST read first)
|
|
2. Read: .workflow/project-tech.json (if exists)
|
|
3. Read: .workflow/project-guidelines.json (if exists)
|
|
|
|
---
|
|
|
|
## Task Objective
|
|
Discover artifacts that have drifted from the development mainline. Identify stale sessions, abandoned documents, and dead code for cleanup.
|
|
|
|
## Context
|
|
- **Session Folder**: ${sessionFolder}
|
|
- **Mainline Profile**: ${sessionFolder}/mainline-profile.json
|
|
- **Focus Area**: ${focusArea || 'entire project'}
|
|
- **Project Root**: ${projectRoot}
|
|
|
|
## Discovery Categories
|
|
|
|
### Category 1: Stale Workflow Sessions
|
|
Scan and analyze workflow session directories:
|
|
|
|
**Locations to scan**:
|
|
- .workflow/active/WFS-* (active sessions)
|
|
- .workflow/archives/WFS-* (archived sessions)
|
|
- .workflow/.lite-plan/* (lite-plan sessions)
|
|
- .workflow/.debug/DBG-* (debug sessions)
|
|
|
|
**Staleness criteria**:
|
|
- Active sessions: No modification >7 days + no related git commits
|
|
- Archives: >30 days old + no feature references in project-tech.json
|
|
- Lite-plan: >7 days old + plan.json not executed
|
|
- Debug: >3 days old + issue not in recent commits
|
|
|
|
**Analysis steps**:
|
|
1. List all session directories with modification times
|
|
2. Cross-reference with git log (are session topics in recent commits?)
|
|
3. Check manifest.json for orphan entries
|
|
4. Identify sessions with .archiving marker (interrupted)
|
|
|
|
### Category 2: Drifted Documents
|
|
Scan documentation that no longer aligns with code:
|
|
|
|
**Locations to scan**:
|
|
- .claude/rules/tech/* (generated tech rules)
|
|
- .workflow/.scratchpad/* (temporary notes)
|
|
- **/CLAUDE.md (module documentation)
|
|
|
|
**Drift criteria**:
|
|
- Tech rules: Referenced files no longer exist
|
|
- Scratchpad: Any file (always temporary)
|
|
- Module docs: Describe functions/classes that were removed
|
|
|
|
**Analysis steps**:
|
|
1. Parse document content for file/function references
|
|
2. Verify referenced entities still exist in codebase
|
|
3. Flag documents with >30% broken references
|
|
|
|
### Category 3: Dead Code
|
|
Identify code that is no longer used:
|
|
|
|
**Scan patterns**:
|
|
- Unused exports (exported but never imported)
|
|
- Orphan files (not imported anywhere)
|
|
- Commented-out code blocks (>10 lines)
|
|
- TODO/FIXME comments >90 days old
|
|
|
|
**Analysis steps**:
|
|
1. Build import graph using rg/grep
|
|
2. Identify exports with no importers
|
|
3. Find files not in import graph
|
|
4. Scan for large comment blocks
|
|
|
|
## Output Format
|
|
|
|
Write to: ${sessionFolder}/cleanup-manifest.json
|
|
|
|
\`\`\`json
|
|
{
|
|
"generated_at": "ISO timestamp",
|
|
"mainline_summary": {
|
|
"core_modules": ["src/core", "src/api"],
|
|
"active_branches": ["main", "feature/auth"],
|
|
"health_score": 0.85
|
|
},
|
|
"discoveries": {
|
|
"stale_sessions": [
|
|
{
|
|
"path": ".workflow/active/WFS-old-feature",
|
|
"type": "active",
|
|
"age_days": 15,
|
|
"reason": "No related commits in 15 days",
|
|
"size_kb": 1024,
|
|
"risk": "low"
|
|
}
|
|
],
|
|
"drifted_documents": [
|
|
{
|
|
"path": ".claude/rules/tech/deprecated-lib",
|
|
"type": "tech_rules",
|
|
"broken_references": 5,
|
|
"total_references": 6,
|
|
"drift_percentage": 83,
|
|
"reason": "Referenced library removed",
|
|
"risk": "low"
|
|
}
|
|
],
|
|
"dead_code": [
|
|
{
|
|
"path": "src/utils/legacy.ts",
|
|
"type": "orphan_file",
|
|
"reason": "Not imported by any file",
|
|
"last_modified": "2025-10-01",
|
|
"risk": "medium"
|
|
}
|
|
]
|
|
},
|
|
"summary": {
|
|
"total_items": 12,
|
|
"total_size_mb": 45.2,
|
|
"by_category": {
|
|
"stale_sessions": 5,
|
|
"drifted_documents": 4,
|
|
"dead_code": 3
|
|
},
|
|
"by_risk": {
|
|
"low": 8,
|
|
"medium": 3,
|
|
"high": 1
|
|
}
|
|
}
|
|
}
|
|
\`\`\`
|
|
|
|
## Execution Commands
|
|
|
|
\`\`\`bash
|
|
# Session directories (cross-platform)
|
|
find .workflow -type d \\( -name "WFS-*" -o -name "DBG-*" \\) 2>/dev/null
|
|
|
|
# Check modification times
|
|
find .workflow -type d -name "WFS-*" -mtime +7 2>/dev/null
|
|
|
|
# Find orphan exports (TypeScript)
|
|
rg "export (const|function|class|interface|type)" --type ts -l
|
|
|
|
# Find imports
|
|
rg "import.*from" --type ts
|
|
|
|
# Find large comment blocks
|
|
rg "^\\s*/\\*" -A 10 --type ts
|
|
|
|
# Find old TODOs
|
|
rg "TODO|FIXME" --type ts -n
|
|
\`\`\`
|
|
|
|
## Success Criteria
|
|
- [ ] All session directories scanned with age calculation
|
|
- [ ] Documents cross-referenced with existing code
|
|
- [ ] Dead code detection via import graph analysis
|
|
- [ ] cleanup-manifest.json written with complete data
|
|
- [ ] Each item has risk level and cleanup reason
|
|
`
|
|
})
|
|
|
|
// Step 2: Wait for discovery results with timeout handling
|
|
console.log('Waiting for discovery agent (timeout: 10 minutes)...')
|
|
let exploreResult = wait({
|
|
ids: [exploreAgent],
|
|
timeout_ms: 600000 // 10 minutes
|
|
})
|
|
|
|
// Step 3: Handle timeout with retry
|
|
if (exploreResult.timed_out) {
|
|
console.log('Warning: Discovery agent timed out. Sending prompt to complete...')
|
|
|
|
// Send prompt to encourage completion
|
|
send_input({
|
|
id: exploreAgent,
|
|
message: 'Please complete the analysis now and write cleanup-manifest.json with the findings so far.'
|
|
})
|
|
|
|
// Wait for additional time
|
|
const retryResult = wait({
|
|
ids: [exploreAgent],
|
|
timeout_ms: 300000 // 5 more minutes
|
|
})
|
|
|
|
if (retryResult.timed_out) {
|
|
console.error('Error: Discovery agent timed out twice. Aborting.')
|
|
throw new Error('Discovery agent timeout')
|
|
}
|
|
}
|
|
|
|
// Step 4: Verify manifest file exists
|
|
if (!fileExists(`${sessionFolder}/cleanup-manifest.json`)) {
|
|
console.error('Error: cleanup-manifest.json not generated by exploration agent')
|
|
throw new Error('Manifest file not generated')
|
|
}
|
|
|
|
console.log('Discovery completed successfully.')
|
|
|
|
} finally {
|
|
// Step 5: Always cleanup agent (try-finally ensures cleanup)
|
|
if (exploreAgent) {
|
|
close_agent({ id: exploreAgent })
|
|
console.log('Discovery agent closed.')
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### Phase 3: Confirmation
|
|
|
|
**Step 3.1: Load and Validate Manifest**
|
|
```javascript
|
|
// Read manifest with error handling
|
|
const manifestContent = Read(`${sessionFolder}/cleanup-manifest.json`)
|
|
const manifest = safeJsonParse(manifestContent)
|
|
|
|
if (!manifest) {
|
|
console.error('Error: Failed to parse cleanup-manifest.json')
|
|
console.log('Attempting to regenerate from filesystem scan...')
|
|
// Fallback: could trigger re-scan here
|
|
return
|
|
}
|
|
|
|
// Validate manifest schema
|
|
const validation = validateManifestSchema(manifest)
|
|
if (!validation.valid) {
|
|
console.error('Error: Manifest validation failed:')
|
|
validation.errors.forEach(e => console.error(` - ${e}`))
|
|
return
|
|
}
|
|
|
|
console.log('Manifest validated successfully.')
|
|
```
|
|
|
|
**Step 3.2: Display Summary**
|
|
```javascript
|
|
console.log(`
|
|
## Cleanup Discovery Report
|
|
|
|
**Mainline Health**: ${Math.round((manifest.mainline_summary?.health_score || 0) * 100)}%
|
|
**Core Modules**: ${(manifest.mainline_summary?.core_modules || []).join(', ') || 'N/A'}
|
|
|
|
### Summary
|
|
| Category | Count | Risk Distribution |
|
|
|----------|-------|-------------------|
|
|
| Stale Sessions | ${manifest.summary.by_category.stale_sessions} | ${getRiskSummary(manifest, 'sessions')} |
|
|
| Drifted Documents | ${manifest.summary.by_category.drifted_documents} | ${getRiskSummary(manifest, 'documents')} |
|
|
| Dead Code | ${manifest.summary.by_category.dead_code} | ${getRiskSummary(manifest, 'code')} |
|
|
|
|
**Total**: ${manifest.summary.total_items} items, ~${manifest.summary.total_size_mb || 0} MB
|
|
|
|
### Stale Sessions
|
|
${(manifest.discoveries.stale_sessions || []).map(s =>
|
|
`- ${s.path} (${s.age_days}d, ${s.risk}): ${s.reason}`
|
|
).join('\n') || '(none)'}
|
|
|
|
### Drifted Documents
|
|
${(manifest.discoveries.drifted_documents || []).map(d =>
|
|
`- ${d.path} (${d.drift_percentage}% broken, ${d.risk}): ${d.reason}`
|
|
).join('\n') || '(none)'}
|
|
|
|
### Dead Code
|
|
${(manifest.discoveries.dead_code || []).map(c =>
|
|
`- ${c.path} (${c.type}, ${c.risk}): ${c.reason}`
|
|
).join('\n') || '(none)'}
|
|
`)
|
|
```
|
|
|
|
**Step 3.3: Dry-Run Exit**
|
|
```javascript
|
|
if (isDryRun) {
|
|
console.log(`
|
|
---
|
|
**Dry-run mode**: No changes made.
|
|
Manifest saved to: ${sessionFolder}/cleanup-manifest.json
|
|
|
|
To execute cleanup: /workflow:clean
|
|
`)
|
|
return
|
|
}
|
|
```
|
|
|
|
**Step 3.4: User Confirmation**
|
|
```javascript
|
|
// Skip confirmation if no items to clean
|
|
if (manifest.summary.total_items === 0) {
|
|
console.log('Codebase is clean. No items to delete.')
|
|
return
|
|
}
|
|
|
|
const userSelection = AskUserQuestion({
|
|
questions: [
|
|
{
|
|
question: "Which categories to clean?",
|
|
header: "Categories",
|
|
multiSelect: true,
|
|
options: [
|
|
{
|
|
label: "Sessions",
|
|
description: `${manifest.summary.by_category.stale_sessions} stale workflow sessions`
|
|
},
|
|
{
|
|
label: "Documents",
|
|
description: `${manifest.summary.by_category.drifted_documents} drifted documents`
|
|
},
|
|
{
|
|
label: "Dead Code",
|
|
description: `${manifest.summary.by_category.dead_code} unused code files`
|
|
}
|
|
]
|
|
},
|
|
{
|
|
question: "Risk level to include?",
|
|
header: "Risk",
|
|
multiSelect: false,
|
|
options: [
|
|
{ label: "Low only", description: "Safest - only obviously stale items (Recommended)" },
|
|
{ label: "Low + Medium", description: "Includes likely unused items" },
|
|
{ label: "All", description: "Aggressive - includes high-risk items" }
|
|
]
|
|
}
|
|
]
|
|
})
|
|
```
|
|
|
|
---
|
|
|
|
### Phase 4: Execution
|
|
|
|
**Step 4.1: Filter Items by Selection**
|
|
```javascript
|
|
const selectedCategories = userSelection.categories || []
|
|
const riskLevel = userSelection.risk || 'Low only'
|
|
|
|
const riskFilter = {
|
|
'Low only': ['low'],
|
|
'Low + Medium': ['low', 'medium'],
|
|
'All': ['low', 'medium', 'high']
|
|
}[riskLevel]
|
|
|
|
const itemsToClean = []
|
|
|
|
if (selectedCategories.includes('Sessions')) {
|
|
itemsToClean.push(...(manifest.discoveries.stale_sessions || []).filter(s => riskFilter.includes(s.risk)))
|
|
}
|
|
if (selectedCategories.includes('Documents')) {
|
|
itemsToClean.push(...(manifest.discoveries.drifted_documents || []).filter(d => riskFilter.includes(d.risk)))
|
|
}
|
|
if (selectedCategories.includes('Dead Code')) {
|
|
itemsToClean.push(...(manifest.discoveries.dead_code || []).filter(c => riskFilter.includes(c.risk)))
|
|
}
|
|
|
|
if (itemsToClean.length === 0) {
|
|
console.log('No items match the selected criteria.')
|
|
return
|
|
}
|
|
|
|
console.log(`\nPreparing to clean ${itemsToClean.length} items...`)
|
|
|
|
// Create todo list for tracking
|
|
TodoWrite({
|
|
todos: itemsToClean.map(item => ({
|
|
content: `Clean: ${item.path}`,
|
|
status: "pending",
|
|
activeForm: `Cleaning ${item.path}`
|
|
}))
|
|
})
|
|
```
|
|
|
|
**Step 4.2: Validate All Paths (Security Check)**
|
|
```javascript
|
|
const validItems = []
|
|
const invalidItems = []
|
|
|
|
for (const item of itemsToClean) {
|
|
const pathValidation = validatePathForDeletion(item.path, projectRoot)
|
|
if (pathValidation.valid) {
|
|
validItems.push(item)
|
|
} else {
|
|
invalidItems.push({ ...item, validationError: pathValidation.reason })
|
|
}
|
|
}
|
|
|
|
if (invalidItems.length > 0) {
|
|
console.log(`\nWarning: ${invalidItems.length} items failed security validation:`)
|
|
invalidItems.forEach(item => {
|
|
console.log(` - ${item.path}: ${item.validationError}`)
|
|
})
|
|
}
|
|
|
|
if (validItems.length === 0) {
|
|
console.log('No valid items to clean after security validation.')
|
|
return
|
|
}
|
|
|
|
console.log(`\n${validItems.length} items passed security validation.`)
|
|
```
|
|
|
|
**Step 4.3: Stage Deletion (Move to .trash)**
|
|
```javascript
|
|
const results = {
|
|
staged: [],
|
|
deleted: [],
|
|
failed: [],
|
|
skipped: invalidItems.map(i => ({ path: i.path, reason: i.validationError }))
|
|
}
|
|
|
|
console.log('\nStaging items for deletion...')
|
|
|
|
for (const item of validItems) {
|
|
try {
|
|
// Check if path exists
|
|
const pathType = dirExists(item.path) ? 'dir' : (fileExists(item.path) ? 'file' : null)
|
|
|
|
if (!pathType) {
|
|
results.skipped.push({ path: item.path, reason: 'Path does not exist' })
|
|
continue
|
|
}
|
|
|
|
// Create target directory in trash
|
|
const trashTarget = `${trashFolder}/${item.path.replace(/\//g, '_')}`
|
|
|
|
// Move to trash (staging)
|
|
Bash({ command: `mv "${item.path}" "${trashTarget}"` })
|
|
|
|
results.staged.push({
|
|
path: item.path,
|
|
trashPath: trashTarget,
|
|
type: pathType
|
|
})
|
|
|
|
} catch (error) {
|
|
results.failed.push({ path: item.path, error: error.message })
|
|
}
|
|
}
|
|
|
|
console.log(`Staged: ${results.staged.length}, Failed: ${results.failed.length}, Skipped: ${results.skipped.length}`)
|
|
```
|
|
|
|
**Step 4.4: Update Manifests**
|
|
```javascript
|
|
// Update archives manifest if sessions were deleted
|
|
if (selectedCategories.includes('Sessions') && results.staged.length > 0) {
|
|
const archiveManifestPath = '.workflow/archives/manifest.json'
|
|
|
|
if (fileExists(archiveManifestPath)) {
|
|
try {
|
|
const archiveContent = Read(archiveManifestPath)
|
|
const archiveManifest = safeJsonParse(archiveContent, [])
|
|
|
|
if (Array.isArray(archiveManifest)) {
|
|
const deletedSessionIds = results.staged
|
|
.filter(s => s.path.includes('WFS-'))
|
|
.map(s => s.path.split('/').pop())
|
|
|
|
const updatedManifest = archiveManifest.filter(entry =>
|
|
!deletedSessionIds.includes(entry.session_id)
|
|
)
|
|
|
|
Write(archiveManifestPath, JSON.stringify(updatedManifest, null, 2))
|
|
console.log('Updated archives manifest.')
|
|
}
|
|
} catch (error) {
|
|
console.error(`Warning: Failed to update archives manifest: ${error.message}`)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Update project-tech.json if features referenced deleted sessions
|
|
const projectPath = '.workflow/project-tech.json'
|
|
|
|
if (fileExists(projectPath)) {
|
|
try {
|
|
const projectContent = Read(projectPath)
|
|
const project = safeJsonParse(projectContent)
|
|
|
|
if (project && Array.isArray(project.features)) {
|
|
const deletedPaths = new Set(results.staged.map(s => s.path))
|
|
|
|
project.features = project.features.filter(f =>
|
|
!deletedPaths.has(f.traceability?.archive_path)
|
|
)
|
|
|
|
project.statistics = project.statistics || {}
|
|
project.statistics.total_features = project.features.length
|
|
project.statistics.last_updated = getUtc8ISOString()
|
|
|
|
Write(projectPath, JSON.stringify(project, null, 2))
|
|
console.log('Updated project-tech.json.')
|
|
}
|
|
} catch (error) {
|
|
console.error(`Warning: Failed to update project-tech.json: ${error.message}`)
|
|
}
|
|
}
|
|
```
|
|
|
|
**Step 4.5: Permanent Deletion**
|
|
```javascript
|
|
console.log('\nPermanently deleting staged items...')
|
|
|
|
for (const staged of results.staged) {
|
|
try {
|
|
Bash({ command: `rm -rf "${staged.trashPath}"` })
|
|
results.deleted.push(staged.path)
|
|
} catch (error) {
|
|
console.error(`Failed to delete ${staged.trashPath}: ${error.message}`)
|
|
// Item remains in trash for manual cleanup
|
|
}
|
|
}
|
|
|
|
// Clean up trash folder if empty
|
|
Bash({ command: `rmdir "${trashFolder}" 2>/dev/null || true` })
|
|
```
|
|
|
|
**Step 4.6: Report Results**
|
|
```javascript
|
|
// Save cleanup report
|
|
const cleanupReport = {
|
|
timestamp: getUtc8ISOString(),
|
|
session_id: sessionId,
|
|
selection: {
|
|
categories: selectedCategories,
|
|
risk_level: riskLevel
|
|
},
|
|
results: {
|
|
deleted: results.deleted,
|
|
failed: results.failed,
|
|
skipped: results.skipped
|
|
},
|
|
summary: {
|
|
total_deleted: results.deleted.length,
|
|
total_failed: results.failed.length,
|
|
total_skipped: results.skipped.length
|
|
}
|
|
}
|
|
|
|
Write(`${sessionFolder}/cleanup-report.json`, JSON.stringify(cleanupReport, null, 2))
|
|
|
|
// Display final report
|
|
console.log(`
|
|
## Cleanup Complete
|
|
|
|
**Deleted**: ${results.deleted.length} items
|
|
**Failed**: ${results.failed.length} items
|
|
**Skipped**: ${results.skipped.length} items
|
|
|
|
### Deleted Items
|
|
${results.deleted.map(p => `- ${p}`).join('\n') || '(none)'}
|
|
|
|
${results.failed.length > 0 ? `
|
|
### Failed Items
|
|
${results.failed.map(f => `- ${f.path}: ${f.error}`).join('\n')}
|
|
` : ''}
|
|
|
|
${results.skipped.length > 0 ? `
|
|
### Skipped Items
|
|
${results.skipped.map(s => `- ${s.path}: ${s.reason}`).join('\n')}
|
|
` : ''}
|
|
|
|
Reports saved to: ${sessionFolder}/
|
|
`)
|
|
|
|
// Update todo list
|
|
TodoWrite({
|
|
todos: results.deleted.map(p => ({
|
|
content: `Clean: ${p}`,
|
|
status: "completed",
|
|
activeForm: `Cleaned ${p}`
|
|
}))
|
|
})
|
|
```
|
|
|
|
---
|
|
|
|
## Session Folder Structure
|
|
|
|
```
|
|
.workflow/.clean/{YYYY-MM-DD}/
|
|
├── mainline-profile.json # Git history analysis
|
|
├── cleanup-manifest.json # Discovery results from agent
|
|
├── cleanup-report.json # Execution results
|
|
└── .trash/ # Staging area (temporary)
|
|
```
|
|
|
|
## Risk Level Definitions
|
|
|
|
| Risk | Description | Examples |
|
|
|------|-------------|----------|
|
|
| **Low** | Safe to delete, no dependencies | Empty sessions, scratchpad files, 100% broken docs |
|
|
| **Medium** | Likely unused, verify before delete | Orphan files, old archives, partially broken docs |
|
|
| **High** | May have hidden dependencies | Files with some imports, recent modifications |
|
|
|
|
## Security Features
|
|
|
|
| Feature | Description |
|
|
|---------|-------------|
|
|
| **Path Validation** | All paths checked against whitelist before deletion |
|
|
| **Traversal Protection** | Paths with `..` or outside project rejected |
|
|
| **Staged Deletion** | Items moved to .trash before permanent deletion |
|
|
| **Schema Validation** | Manifest validated before processing |
|
|
| **Safe JSON Parsing** | All JSON operations use try-catch |
|
|
|
|
## Error Handling
|
|
|
|
| Situation | Action |
|
|
|-----------|--------|
|
|
| No git repository | Skip mainline detection, use file timestamps only |
|
|
| Session in use (.archiving) | Skip with warning |
|
|
| Permission denied | Report error, continue with others |
|
|
| Manifest parse error | Abort with error message |
|
|
| Schema validation failure | Abort with detailed errors |
|
|
| Path validation failure | Skip item, report reason |
|
|
| Subagent timeout | Send prompt, retry once, then abort |
|
|
| Empty discovery | Report "codebase is clean" |
|
|
|
|
## Testing Scenarios
|
|
|
|
| Scenario | Expected Behavior |
|
|
|----------|-------------------|
|
|
| No items to clean | Report "codebase is clean", exit gracefully |
|
|
| All items fail validation | Report validation errors, no deletion |
|
|
| Mixed valid/invalid items | Delete valid items, skip invalid with reasons |
|
|
| Manifest parse error | Abort with clear error message |
|
|
| Agent timeout | Retry once with prompt, then abort |
|
|
| Partial deletion failure | Complete successful items, report failures |
|
|
| Dry-run mode | Display summary, no deletion, save manifest |
|
|
|
|
## Related Commands
|
|
|
|
- `/workflow:session:complete` - Properly archive active sessions
|
|
- `/memory:compact` - Save session memory before cleanup
|
|
- `/workflow:status` - View current workflow state
|