feat: Enhance issue and solution management with new UI components and functionality

- Added internationalization support for new issue and solution-related strings in i18n.js.
- Implemented a solution detail modal in issue-manager.js to display solution information and bind/unbind actions.
- Enhanced the skill loading function to combine project and user skills in hook-manager.js.
- Improved queue rendering logic to handle empty states and display queue statistics in issue-manager.js.
- Introduced command modals for queue operations, allowing users to generate execution queues via CLI commands.
- Added functionality to auto-generate issue IDs and regenerate them in the create issue modal.
- Implemented detailed rendering of solution tasks, including acceptance criteria and modification points.
This commit is contained in:
catlog22
2025-12-27 11:27:45 +08:00
parent 8f310339df
commit 4da06864f8
11 changed files with 2490 additions and 169 deletions

View File

@@ -199,7 +199,7 @@ async function ripgrepFallback(issue, projectRoot) {
## Phase 3: Solution Planning ## Phase 3: Solution Planning
### Task Decomposition ### Task Decomposition (Closed-Loop)
```javascript ```javascript
function decomposeTasks(issue, exploration) { function decomposeTasks(issue, exploration) {
@@ -217,15 +217,104 @@ function decomposeTasks(issue, exploration) {
action: inferAction(group), action: inferAction(group),
description: group.description, description: group.description,
modification_points: group.points, modification_points: group.points,
// Phase 1: Implementation
implementation: generateImplementationSteps(group, exploration), implementation: generateImplementationSteps(group, exploration),
// Phase 2: Test
test: generateTestRequirements(group, exploration, issue.lifecycle_requirements),
// Phase 3: Regression
regression: generateRegressionChecks(group, issue.lifecycle_requirements),
// Phase 4: Acceptance
acceptance: generateAcceptanceCriteria(group), acceptance: generateAcceptanceCriteria(group),
// Phase 5: Commit
commit: generateCommitSpec(group, issue),
depends_on: inferDependencies(group, tasks), depends_on: inferDependencies(group, tasks),
estimated_minutes: estimateTime(group) estimated_minutes: estimateTime(group),
executor: inferExecutor(group)
}) })
} }
return tasks return tasks
} }
function generateTestRequirements(group, exploration, lifecycle) {
const test = {
unit: [],
integration: [],
commands: [],
coverage_target: 80
}
// Generate unit test requirements based on action
if (group.action === 'Create' || group.action === 'Implement') {
test.unit.push(`Test ${group.title} happy path`)
test.unit.push(`Test ${group.title} error cases`)
}
// Generate test commands based on project patterns
if (exploration.test_patterns?.includes('jest')) {
test.commands.push(`npm test -- --grep '${group.scope}'`)
} else if (exploration.test_patterns?.includes('vitest')) {
test.commands.push(`npx vitest run ${group.scope}`)
} else {
test.commands.push(`npm test`)
}
// Add integration tests if needed
if (lifecycle?.test_strategy === 'integration' || lifecycle?.test_strategy === 'e2e') {
test.integration.push(`Integration test for ${group.title}`)
}
return test
}
function generateRegressionChecks(group, lifecycle) {
const regression = []
switch (lifecycle?.regression_scope) {
case 'full':
regression.push('npm test')
regression.push('npm run test:integration')
break
case 'related':
regression.push(`npm test -- --grep '${group.scope}'`)
regression.push(`npm test -- --changed`)
break
case 'affected':
default:
regression.push(`npm test -- --findRelatedTests ${group.points[0]?.file}`)
break
}
return regression
}
function generateCommitSpec(group, issue) {
const typeMap = {
'Create': 'feat',
'Implement': 'feat',
'Update': 'feat',
'Fix': 'fix',
'Refactor': 'refactor',
'Test': 'test',
'Configure': 'chore',
'Delete': 'chore'
}
const scope = group.scope.split('/').pop()?.replace(/\..*$/, '') || 'core'
return {
type: typeMap[group.action] || 'feat',
scope: scope,
message_template: `${typeMap[group.action] || 'feat'}(${scope}): ${group.title.toLowerCase()}\n\n${group.description || ''}`,
breaking: false
}
}
``` ```
### Action Type Inference ### Action Type Inference
@@ -347,11 +436,15 @@ function generateImplementationSteps(group, exploration) {
} }
``` ```
### Acceptance Criteria Generation ### Acceptance Criteria Generation (Closed-Loop)
```javascript ```javascript
function generateAcceptanceCriteria(task) { function generateAcceptanceCriteria(task) {
const criteria = [] const acceptance = {
criteria: [],
verification: [],
manual_checks: []
}
// Action-specific criteria // Action-specific criteria
const actionCriteria = { const actionCriteria = {
@@ -363,14 +456,41 @@ function generateAcceptanceCriteria(task) {
'Configure': [`Configuration applied correctly`] 'Configure': [`Configuration applied correctly`]
} }
criteria.push(...(actionCriteria[task.action] || [])) acceptance.criteria.push(...(actionCriteria[task.action] || []))
// Add quantified criteria // Add quantified criteria
if (task.modification_points.length > 0) { if (task.modification_points.length > 0) {
criteria.push(`${task.modification_points.length} file(s) modified correctly`) acceptance.criteria.push(`${task.modification_points.length} file(s) modified correctly`)
} }
return criteria.slice(0, 4) // Max 4 criteria // Generate verification steps for each criterion
for (const criterion of acceptance.criteria) {
acceptance.verification.push(generateVerificationStep(criterion, task))
}
// Limit to reasonable counts
acceptance.criteria = acceptance.criteria.slice(0, 4)
acceptance.verification = acceptance.verification.slice(0, 4)
return acceptance
}
function generateVerificationStep(criterion, task) {
// Generate executable verification for criterion
if (criterion.includes('file created')) {
return `ls -la ${task.modification_points[0]?.file} && head -20 ${task.modification_points[0]?.file}`
}
if (criterion.includes('test')) {
return `npm test -- --grep '${task.scope}'`
}
if (criterion.includes('export')) {
return `node -e "console.log(require('./${task.modification_points[0]?.file}'))"`
}
if (criterion.includes('API') || criterion.includes('endpoint')) {
return `curl -X GET http://localhost:3000/${task.scope} -v`
}
// Default: describe manual check
return `Manually verify: ${criterion}`
} }
``` ```
@@ -413,20 +533,61 @@ function validateSolution(solution) {
function validateTask(task) { function validateTask(task) {
const errors = [] const errors = []
// Basic fields
if (!/^T\d+$/.test(task.id)) errors.push('Invalid task ID format') if (!/^T\d+$/.test(task.id)) errors.push('Invalid task ID format')
if (!task.title?.trim()) errors.push('Missing title') if (!task.title?.trim()) errors.push('Missing title')
if (!task.scope?.trim()) errors.push('Missing scope') if (!task.scope?.trim()) errors.push('Missing scope')
if (!['Create', 'Update', 'Implement', 'Refactor', 'Configure', 'Test', 'Fix', 'Delete'].includes(task.action)) { if (!['Create', 'Update', 'Implement', 'Refactor', 'Configure', 'Test', 'Fix', 'Delete'].includes(task.action)) {
errors.push('Invalid action type') errors.push('Invalid action type')
} }
// Phase 1: Implementation
if (!task.implementation || task.implementation.length < 2) { if (!task.implementation || task.implementation.length < 2) {
errors.push('Need 2+ implementation steps') errors.push('Need 2+ implementation steps')
} }
if (!task.acceptance || task.acceptance.length < 1) {
errors.push('Need 1+ acceptance criteria') // Phase 2: Test
if (!task.test) {
errors.push('Missing test phase')
} else {
if (!task.test.commands || task.test.commands.length < 1) {
errors.push('Need 1+ test commands')
}
} }
if (task.acceptance?.some(a => /works correctly|good performance|properly/i.test(a))) {
errors.push('Vague acceptance criteria') // Phase 3: Regression
if (!task.regression || task.regression.length < 1) {
errors.push('Need 1+ regression checks')
}
// Phase 4: Acceptance
if (!task.acceptance) {
errors.push('Missing acceptance phase')
} else {
if (!task.acceptance.criteria || task.acceptance.criteria.length < 1) {
errors.push('Need 1+ acceptance criteria')
}
if (!task.acceptance.verification || task.acceptance.verification.length < 1) {
errors.push('Need 1+ verification steps')
}
if (task.acceptance.criteria?.some(a => /works correctly|good performance|properly/i.test(a))) {
errors.push('Vague acceptance criteria')
}
}
// Phase 5: Commit
if (!task.commit) {
errors.push('Missing commit phase')
} else {
if (!['feat', 'fix', 'refactor', 'test', 'docs', 'chore'].includes(task.commit.type)) {
errors.push('Invalid commit type')
}
if (!task.commit.scope?.trim()) {
errors.push('Missing commit scope')
}
if (!task.commit.message_template?.trim()) {
errors.push('Missing commit message template')
}
} }
return errors return errors
@@ -500,7 +661,9 @@ function generateOutput(solutions, conflicts) {
} }
``` ```
### Solution Schema ### Solution Schema (Closed-Loop Tasks)
Each task MUST include ALL 5 lifecycle phases:
```json ```json
{ {
@@ -517,10 +680,62 @@ function generateOutput(solutions, conflicts) {
"modification_points": [ "modification_points": [
{ "file": "src/middleware/auth.ts", "target": "new file", "change": "Create middleware" } { "file": "src/middleware/auth.ts", "target": "new file", "change": "Create middleware" }
], ],
"implementation": ["Step 1", "Step 2", "..."],
"acceptance": ["Criterion 1", "Criterion 2"], "implementation": [
"Create auth.ts file in src/middleware/",
"Implement JWT token extraction from Authorization header",
"Add token validation using jsonwebtoken library",
"Handle error cases (missing, invalid, expired tokens)",
"Export middleware function"
],
"test": {
"unit": [
"Test valid token passes through",
"Test invalid token returns 401",
"Test expired token returns 401",
"Test missing token returns 401"
],
"integration": [
"Protected route returns 401 without token",
"Protected route returns 200 with valid token"
],
"commands": [
"npm test -- --grep 'auth middleware'",
"npm run test:coverage -- src/middleware/auth.ts"
],
"coverage_target": 80
},
"regression": [
"npm test -- --grep 'existing routes'",
"npm run test:integration"
],
"acceptance": {
"criteria": [
"Middleware validates JWT tokens successfully",
"Returns 401 with appropriate error for invalid tokens",
"Passes decoded user payload to request context"
],
"verification": [
"curl -H 'Authorization: Bearer <valid>' /api/protected → 200",
"curl /api/protected → 401 {error: 'No token'}",
"curl -H 'Authorization: Bearer invalid' /api/protected → 401"
],
"manual_checks": []
},
"commit": {
"type": "feat",
"scope": "auth",
"message_template": "feat(auth): add JWT validation middleware\n\n- Implement token extraction and validation\n- Add error handling for invalid/expired tokens\n- Export middleware for route protection",
"breaking": false
},
"depends_on": [], "depends_on": [],
"estimated_minutes": 30 "estimated_minutes": 30,
"executor": "codex"
} }
], ],
"exploration_context": { "exploration_context": {
@@ -622,6 +837,14 @@ Before outputting solution:
6. Include file:line references in modification_points where possible 6. Include file:line references in modification_points where possible
7. Detect and report cross-issue file conflicts in batch mode 7. Detect and report cross-issue file conflicts in batch mode
8. Include exploration_context with patterns and relevant_files 8. Include exploration_context with patterns and relevant_files
9. **Generate ALL 5 lifecycle phases for each task**:
- `implementation`: 2-7 concrete steps
- `test`: unit tests, commands, coverage target
- `regression`: regression check commands
- `acceptance`: criteria + verification steps
- `commit`: type, scope, message template
10. Infer test commands from project's test framework
11. Generate commit message following conventional commits
**NEVER**: **NEVER**:
1. Execute implementation (return plan only) 1. Execute implementation (return plan only)
@@ -632,3 +855,5 @@ Before outputting solution:
6. Assume file exists without verification 6. Assume file exists without verification
7. Generate more than 10 tasks per issue 7. Generate more than 10 tasks per issue
8. Skip ACE search (unless fallback triggered) 8. Skip ACE search (unless fallback triggered)
9. **Omit any of the 5 lifecycle phases** (test, regression, acceptance, commit)
10. Skip verification steps in acceptance criteria

View File

@@ -148,15 +148,15 @@ TodoWrite({
}); });
``` ```
### Phase 3: Codex Coordination (Single Task Mode) ### Phase 3: Codex Coordination (Single Task Mode - Full Lifecycle)
```javascript ```javascript
// Execute tasks - single codex instance per task // Execute tasks - single codex instance per task with full lifecycle
async function executeTask(queueItem) { async function executeTask(queueItem) {
const codexPrompt = ` const codexPrompt = `
## Single Task Execution ## Single Task Execution - CLOSED-LOOP LIFECYCLE
You are executing ONE task from the issue queue. Follow these steps exactly: You are executing ONE task from the issue queue. Each task has 5 phases that MUST ALL complete successfully.
### Step 1: Fetch Task ### Step 1: Fetch Task
Run this command to get your task: Run this command to get your task:
@@ -164,35 +164,71 @@ Run this command to get your task:
ccw issue next ccw issue next
\`\`\` \`\`\`
This returns JSON with: This returns JSON with full lifecycle definition:
- queue_id: Queue item ID - task.implementation: Implementation steps
- task: Task definition with implementation steps - task.test: Test requirements and commands
- context: Exploration context - task.regression: Regression check commands
- execution_hints: Executor and time estimate - task.acceptance: Acceptance criteria and verification
- task.commit: Commit specification
### Step 2: Execute Task ### Step 2: Execute Full Lifecycle
Read the returned task object and:
**Phase 1: IMPLEMENT**
1. Follow task.implementation steps in order 1. Follow task.implementation steps in order
2. Meet all task.acceptance criteria 2. Modify files specified in modification_points
3. Use provided context.relevant_files for reference 3. Use context.relevant_files for reference
4. Use context.patterns for code style 4. Use context.patterns for code style
**Phase 2: TEST**
1. Run test commands from task.test.commands
2. Ensure all unit tests pass (task.test.unit)
3. Run integration tests if specified (task.test.integration)
4. Verify coverage meets task.test.coverage_target if specified
5. If tests fail → fix code and re-run, do NOT proceed until tests pass
**Phase 3: REGRESSION**
1. Run all commands in task.regression
2. Ensure no existing tests are broken
3. If regression fails → fix and re-run
**Phase 4: ACCEPTANCE**
1. Verify each criterion in task.acceptance.criteria
2. Execute verification steps in task.acceptance.verification
3. Complete any manual_checks if specified
4. All criteria MUST pass before proceeding
**Phase 5: COMMIT**
1. Stage all modified files
2. Use task.commit.message_template as commit message
3. Commit with: git commit -m "$(cat <<'EOF'\n<message>\nEOF\n)"
4. If commit_strategy is 'per-task', commit now
5. If commit_strategy is 'atomic' or 'squash', stage but don't commit
### Step 3: Report Completion ### Step 3: Report Completion
When done, run: When ALL phases complete successfully:
\`\`\`bash \`\`\`bash
ccw issue complete <queue_id> --result '{"files_modified": ["path1", "path2"], "summary": "What was done"}' ccw issue complete <queue_id> --result '{
"files_modified": ["path1", "path2"],
"tests_passed": true,
"regression_passed": true,
"acceptance_passed": true,
"committed": true,
"commit_hash": "<hash>",
"summary": "What was done"
}'
\`\`\` \`\`\`
If task fails, run: If any phase fails and cannot be fixed:
\`\`\`bash \`\`\`bash
ccw issue fail <queue_id> --reason "Why it failed" ccw issue fail <queue_id> --reason "Phase X failed: <details>"
\`\`\` \`\`\`
### Rules ### Rules
- NEVER read task files directly - use ccw issue next - NEVER skip any lifecycle phase
- Execute the FULL task before marking complete - Tests MUST pass before proceeding to acceptance
- Do NOT loop - execute ONE task only - Regression MUST pass before commit
- Report accurate files_modified in result - ALL acceptance criteria MUST be verified
- Report accurate lifecycle status in result
### Start Now ### Start Now
Begin by running: ccw issue next Begin by running: ccw issue next

View File

@@ -15,7 +15,7 @@ Creates a new structured issue from either:
Outputs a well-formed issue entry to `.workflow/issues/issues.jsonl`. Outputs a well-formed issue entry to `.workflow/issues/issues.jsonl`.
## Issue Structure ## Issue Structure (Closed-Loop)
```typescript ```typescript
interface Issue { interface Issue {
@@ -27,14 +27,22 @@ interface Issue {
source: 'github' | 'text'; // Input source type source: 'github' | 'text'; // Input source type
source_url?: string; // GitHub URL if applicable source_url?: string; // GitHub URL if applicable
labels?: string[]; // Categorization labels labels?: string[]; // Categorization labels
// Structured extraction // Structured extraction
problem_statement: string; // What is the problem? problem_statement: string; // What is the problem?
expected_behavior?: string; // What should happen? expected_behavior?: string; // What should happen?
actual_behavior?: string; // What actually happens? actual_behavior?: string; // What actually happens?
affected_components?: string[];// Files/modules affected affected_components?: string[];// Files/modules affected
reproduction_steps?: string[]; // Steps to reproduce reproduction_steps?: string[]; // Steps to reproduce
// Closed-loop requirements (guide plan generation)
lifecycle_requirements: {
test_strategy: 'unit' | 'integration' | 'e2e' | 'manual' | 'auto';
regression_scope: 'affected' | 'related' | 'full'; // Which tests to run
acceptance_type: 'automated' | 'manual' | 'both'; // How to verify
commit_strategy: 'per-task' | 'squash' | 'atomic'; // Commit granularity
};
// Metadata // Metadata
bound_solution_id: null; bound_solution_id: null;
solution_count: 0; solution_count: 0;
@@ -43,6 +51,52 @@ interface Issue {
} }
``` ```
## Task Lifecycle (Each Task is Closed-Loop)
When `/issue:plan` generates tasks, each task MUST include:
```typescript
interface SolutionTask {
id: string;
title: string;
scope: string;
action: string;
// Phase 1: Implementation
implementation: string[]; // Step-by-step implementation
modification_points: { file: string; target: string; change: string }[];
// Phase 2: Testing
test: {
unit?: string[]; // Unit test requirements
integration?: string[]; // Integration test requirements
commands?: string[]; // Test commands to run
coverage_target?: number; // Minimum coverage %
};
// Phase 3: Regression
regression: string[]; // Regression check commands/points
// Phase 4: Acceptance
acceptance: {
criteria: string[]; // Testable acceptance criteria
verification: string[]; // How to verify each criterion
manual_checks?: string[]; // Manual verification if needed
};
// Phase 5: Commit
commit: {
type: 'feat' | 'fix' | 'refactor' | 'test' | 'docs' | 'chore';
scope: string; // e.g., "auth", "api"
message_template: string; // Commit message template
breaking?: boolean;
};
depends_on: string[];
executor: 'codex' | 'gemini' | 'agent' | 'auto';
}
```
## Usage ## Usage
```bash ```bash
@@ -206,7 +260,58 @@ async function parseTextDescription(text) {
} }
``` ```
### Phase 4: User Confirmation ### Phase 4: Lifecycle Configuration
```javascript
// Ask for lifecycle requirements (or use smart defaults)
const lifecycleAnswer = AskUserQuestion({
questions: [
{
question: 'Test strategy for this issue?',
header: 'Test',
multiSelect: false,
options: [
{ label: 'auto', description: 'Auto-detect based on affected files (Recommended)' },
{ label: 'unit', description: 'Unit tests only' },
{ label: 'integration', description: 'Integration tests' },
{ label: 'e2e', description: 'End-to-end tests' },
{ label: 'manual', description: 'Manual testing only' }
]
},
{
question: 'Regression scope?',
header: 'Regression',
multiSelect: false,
options: [
{ label: 'affected', description: 'Only affected module tests (Recommended)' },
{ label: 'related', description: 'Affected + dependent modules' },
{ label: 'full', description: 'Full test suite' }
]
},
{
question: 'Commit strategy?',
header: 'Commit',
multiSelect: false,
options: [
{ label: 'per-task', description: 'One commit per task (Recommended)' },
{ label: 'atomic', description: 'Single commit for entire issue' },
{ label: 'squash', description: 'Squash at the end' }
]
}
]
});
const lifecycle = {
test_strategy: lifecycleAnswer.test || 'auto',
regression_scope: lifecycleAnswer.regression || 'affected',
acceptance_type: 'automated',
commit_strategy: lifecycleAnswer.commit || 'per-task'
};
issueData.lifecycle_requirements = lifecycle;
```
### Phase 5: User Confirmation
```javascript ```javascript
// Show parsed data and ask for confirmation // Show parsed data and ask for confirmation
@@ -224,6 +329,11 @@ ${issueData.expected_behavior ? `### Expected Behavior\n${issueData.expected_beh
${issueData.actual_behavior ? `### Actual Behavior\n${issueData.actual_behavior}\n` : ''} ${issueData.actual_behavior ? `### Actual Behavior\n${issueData.actual_behavior}\n` : ''}
${issueData.affected_components?.length ? `### Affected Components\n${issueData.affected_components.map(c => `- ${c}`).join('\n')}\n` : ''} ${issueData.affected_components?.length ? `### Affected Components\n${issueData.affected_components.map(c => `- ${c}`).join('\n')}\n` : ''}
${issueData.reproduction_steps?.length ? `### Reproduction Steps\n${issueData.reproduction_steps.map((s, i) => `${i+1}. ${s}`).join('\n')}\n` : ''} ${issueData.reproduction_steps?.length ? `### Reproduction Steps\n${issueData.reproduction_steps.map((s, i) => `${i+1}. ${s}`).join('\n')}\n` : ''}
### Lifecycle Configuration
- **Test Strategy**: ${lifecycle.test_strategy}
- **Regression Scope**: ${lifecycle.regression_scope}
- **Commit Strategy**: ${lifecycle.commit_strategy}
`); `);
// Ask user to confirm or edit // Ask user to confirm or edit
@@ -264,7 +374,7 @@ if (answer.includes('Edit Title')) {
} }
``` ```
### Phase 5: Write to JSONL ### Phase 6: Write to JSONL
```javascript ```javascript
// Construct final issue object // Construct final issue object
@@ -280,14 +390,22 @@ const newIssue = {
source: issueData.source, source: issueData.source,
source_url: issueData.source_url || null, source_url: issueData.source_url || null,
labels: [...(issueData.labels || []), ...labels], labels: [...(issueData.labels || []), ...labels],
// Structured fields // Structured fields
problem_statement: issueData.problem_statement, problem_statement: issueData.problem_statement,
expected_behavior: issueData.expected_behavior || null, expected_behavior: issueData.expected_behavior || null,
actual_behavior: issueData.actual_behavior || null, actual_behavior: issueData.actual_behavior || null,
affected_components: issueData.affected_components || [], affected_components: issueData.affected_components || [],
reproduction_steps: issueData.reproduction_steps || [], reproduction_steps: issueData.reproduction_steps || [],
// Closed-loop lifecycle requirements
lifecycle_requirements: issueData.lifecycle_requirements || {
test_strategy: 'auto',
regression_scope: 'affected',
acceptance_type: 'automated',
commit_strategy: 'per-task'
},
// Metadata // Metadata
bound_solution_id: null, bound_solution_id: null,
solution_count: 0, solution_count: 0,

View File

@@ -133,28 +133,59 @@ TodoWrite({
for (const [batchIndex, batch] of batches.entries()) { for (const [batchIndex, batch] of batches.entries()) {
updateTodo(`Plan batch ${batchIndex + 1}`, 'in_progress'); updateTodo(`Plan batch ${batchIndex + 1}`, 'in_progress');
// Build issue prompt for agent // Build issue prompt for agent with lifecycle requirements
const issuePrompt = ` const issuePrompt = `
## Issues to Plan ## Issues to Plan (Closed-Loop Tasks Required)
${batch.map((issue, i) => ` ${batch.map((issue, i) => `
### Issue ${i + 1}: ${issue.id} ### Issue ${i + 1}: ${issue.id}
**Title**: ${issue.title} **Title**: ${issue.title}
**Context**: ${issue.context || 'No context provided'} **Context**: ${issue.context || 'No context provided'}
**Affected Components**: ${issue.affected_components?.join(', ') || 'Not specified'}
**Lifecycle Requirements**:
- Test Strategy: ${issue.lifecycle_requirements?.test_strategy || 'auto'}
- Regression Scope: ${issue.lifecycle_requirements?.regression_scope || 'affected'}
- Commit Strategy: ${issue.lifecycle_requirements?.commit_strategy || 'per-task'}
`).join('\n')} `).join('\n')}
## Project Root ## Project Root
${process.cwd()} ${process.cwd()}
## Requirements ## Requirements - CLOSED-LOOP TASKS
Each task MUST include ALL lifecycle phases:
### 1. Implementation
- implementation: string[] (2-7 concrete steps)
- modification_points: { file, target, change }[]
### 2. Test
- test.unit: string[] (unit test requirements)
- test.integration: string[] (integration test requirements if needed)
- test.commands: string[] (actual test commands to run)
- test.coverage_target: number (minimum coverage %)
### 3. Regression
- regression: string[] (commands to run for regression check)
- Based on issue's regression_scope setting
### 4. Acceptance
- acceptance.criteria: string[] (testable acceptance criteria)
- acceptance.verification: string[] (how to verify each criterion)
- acceptance.manual_checks: string[] (manual checks if needed)
### 5. Commit
- commit.type: feat|fix|refactor|test|docs|chore
- commit.scope: string (module name)
- commit.message_template: string (full commit message)
- commit.breaking: boolean
## Additional Requirements
1. Use ACE semantic search (mcp__ace-tool__search_context) for exploration 1. Use ACE semantic search (mcp__ace-tool__search_context) for exploration
2. Generate complete solution with task breakdown 2. Detect file conflicts if multiple issues
3. Each task must have: 3. Generate executable test commands based on project's test framework
- implementation steps (2-7 steps) 4. Infer commit scope from affected files
- acceptance criteria (1-4 testable criteria)
- modification_points (exact file locations)
- depends_on (task dependencies)
4. Detect file conflicts if multiple issues
`; `;
// Launch issue-plan-agent (combines explore + plan) // Launch issue-plan-agent (combines explore + plan)
@@ -281,7 +312,7 @@ ${issues.map(i => {
`); `);
``` ```
## Solution Format ## Solution Format (Closed-Loop Tasks)
Each solution line in `solutions/{issue-id}.jsonl`: Each solution line in `solutions/{issue-id}.jsonl`:
@@ -299,18 +330,56 @@ Each solution line in `solutions/{issue-id}.jsonl`:
"modification_points": [ "modification_points": [
{ "file": "src/middleware/auth.ts", "target": "new file", "change": "Create middleware" } { "file": "src/middleware/auth.ts", "target": "new file", "change": "Create middleware" }
], ],
"implementation": [ "implementation": [
"Create auth.ts file", "Create auth.ts file in src/middleware/",
"Implement JWT validation", "Implement JWT token validation using jsonwebtoken",
"Add error handling", "Add error handling for invalid/expired tokens",
"Export middleware" "Export middleware function"
], ],
"acceptance": [
"Middleware validates JWT tokens", "test": {
"Returns 401 for invalid tokens" "unit": [
"Test valid token passes through",
"Test invalid token returns 401",
"Test expired token returns 401",
"Test missing token returns 401"
],
"commands": [
"npm test -- --grep 'auth middleware'",
"npm run test:coverage -- src/middleware/auth.ts"
],
"coverage_target": 80
},
"regression": [
"npm test -- --grep 'protected routes'",
"npm run test:integration -- auth"
], ],
"acceptance": {
"criteria": [
"Middleware validates JWT tokens successfully",
"Returns 401 for invalid or missing tokens",
"Passes decoded token to request context"
],
"verification": [
"curl -H 'Authorization: Bearer valid_token' /api/protected → 200",
"curl /api/protected → 401",
"curl -H 'Authorization: Bearer invalid' /api/protected → 401"
]
},
"commit": {
"type": "feat",
"scope": "auth",
"message_template": "feat(auth): add JWT validation middleware\n\n- Implement token validation\n- Add error handling for invalid tokens\n- Export for route protection",
"breaking": false
},
"depends_on": [], "depends_on": [],
"estimated_minutes": 30 "estimated_minutes": 30,
"executor": "codex"
} }
], ],
"exploration_context": { "exploration_context": {

View File

@@ -20,30 +20,67 @@ Queue formation command using **issue-queue-agent** that analyzes all bound solu
- Parallel/Sequential group assignment - Parallel/Sequential group assignment
- Output global queue.json - Output global queue.json
## Storage Structure (Flat JSONL) ## Storage Structure (Queue History)
``` ```
.workflow/issues/ .workflow/issues/
├── issues.jsonl # All issues (one per line) ├── issues.jsonl # All issues (one per line)
├── queue.json # Execution queue (output) ├── queues/ # Queue history directory
│ ├── index.json # Queue index (active + history)
│ ├── {queue-id}.json # Individual queue files
│ └── ...
└── solutions/ └── solutions/
├── {issue-id}.jsonl # Solutions for issue ├── {issue-id}.jsonl # Solutions for issue
└── ... └── ...
``` ```
### Queue Index Schema
```json
{
"active_queue_id": "QUE-20251227-143000",
"queues": [
{
"id": "QUE-20251227-143000",
"status": "active",
"issue_ids": ["GH-123", "GH-124"],
"total_tasks": 8,
"completed_tasks": 3,
"created_at": "2025-12-27T14:30:00Z"
},
{
"id": "QUE-20251226-100000",
"status": "completed",
"issue_ids": ["GH-120"],
"total_tasks": 5,
"completed_tasks": 5,
"created_at": "2025-12-26T10:00:00Z",
"completed_at": "2025-12-26T12:30:00Z"
}
]
}
```
## Usage ## Usage
```bash ```bash
/issue:queue [FLAGS] /issue:queue [FLAGS]
# Examples # Examples
/issue:queue # Form queue from all bound solutions /issue:queue # Form NEW queue from all bound solutions
/issue:queue --rebuild # Rebuild queue (clear and regenerate) /issue:queue --issue GH-123 # Form queue for specific issue only
/issue:queue --issue GH-123 # Add only specific issue to queue /issue:queue --append GH-124 # Append to active queue
/issue:queue --list # List all queues (history)
/issue:queue --switch QUE-xxx # Switch active queue
/issue:queue --archive # Archive completed active queue
# Flags # Flags
--rebuild Clear existing queue and regenerate --issue <id> Form queue for specific issue only
--issue <id> Add only specific issue's tasks --append <id> Append issue to active queue (don't create new)
--list List all queues with status
--switch <queue-id> Switch active queue
--archive Archive current queue (mark completed)
--clear <queue-id> Delete a queue from history
``` ```
## Execution Process ## Execution Process
@@ -215,10 +252,15 @@ ${(queueOutput.execution_groups || []).map(g => {
## Queue Schema ## Queue Schema
Output `queue.json`: Output `queues/{queue-id}.json`:
```json ```json
{ {
"id": "QUE-20251227-143000",
"name": "Auth Feature Queue",
"status": "active",
"issue_ids": ["GH-123", "GH-124"],
"queue": [ "queue": [
{ {
"queue_id": "Q-001", "queue_id": "Q-001",
@@ -233,6 +275,7 @@ Output `queue.json`:
"queued_at": "2025-12-26T10:00:00Z" "queued_at": "2025-12-26T10:00:00Z"
} }
], ],
"conflicts": [ "conflicts": [
{ {
"type": "file_conflict", "type": "file_conflict",
@@ -244,24 +287,32 @@ Output `queue.json`:
"resolved": true "resolved": true
} }
], ],
"execution_groups": [ "execution_groups": [
{ "id": "P1", "type": "parallel", "task_count": 3, "tasks": ["GH-123:T1", "GH-124:T1", "GH-125:T1"] }, { "id": "P1", "type": "parallel", "task_count": 3, "tasks": ["GH-123:T1", "GH-124:T1", "GH-125:T1"] },
{ "id": "S2", "type": "sequential", "task_count": 2, "tasks": ["GH-123:T2", "GH-124:T2"] } { "id": "S2", "type": "sequential", "task_count": 2, "tasks": ["GH-123:T2", "GH-124:T2"] }
], ],
"_metadata": { "_metadata": {
"version": "2.0", "version": "2.0",
"storage": "jsonl",
"total_tasks": 5, "total_tasks": 5,
"total_conflicts": 1, "pending_count": 3,
"resolved_conflicts": 1, "completed_count": 2,
"parallel_groups": 1, "failed_count": 0,
"sequential_groups": 1, "created_at": "2025-12-26T10:00:00Z",
"timestamp": "2025-12-26T10:00:00Z", "updated_at": "2025-12-26T11:00:00Z",
"source": "issue-queue-agent" "source": "issue-queue-agent"
} }
} }
``` ```
### Queue ID Format
```
QUE-YYYYMMDD-HHMMSS
例如: QUE-20251227-143052
```
## Semantic Priority Rules ## Semantic Priority Rules
| Factor | Priority Boost | | Factor | Priority Boost |

View File

@@ -36,6 +36,26 @@ interface Issue {
completed_at?: string; completed_at?: string;
} }
interface TaskTest {
unit?: string[]; // Unit test requirements
integration?: string[]; // Integration test requirements
commands?: string[]; // Test commands to run
coverage_target?: number; // Minimum coverage % (optional)
}
interface TaskAcceptance {
criteria: string[]; // Acceptance criteria (testable)
verification: string[]; // How to verify each criterion
manual_checks?: string[]; // Manual verification steps if needed
}
interface TaskCommit {
type: 'feat' | 'fix' | 'refactor' | 'test' | 'docs' | 'chore';
scope: string; // Commit scope (e.g., "auth", "api")
message_template: string; // Commit message template
breaking?: boolean; // Breaking change flag
}
interface SolutionTask { interface SolutionTask {
id: string; id: string;
title: string; title: string;
@@ -43,11 +63,26 @@ interface SolutionTask {
action: string; action: string;
description?: string; description?: string;
modification_points?: { file: string; target: string; change: string }[]; modification_points?: { file: string; target: string; change: string }[];
implementation: string[];
acceptance: string[]; // Lifecycle phases (closed-loop)
implementation: string[]; // Implementation steps
test: TaskTest; // Test requirements
regression: string[]; // Regression check points
acceptance: TaskAcceptance; // Acceptance criteria & verification
commit: TaskCommit; // Commit specification
depends_on: string[]; depends_on: string[];
estimated_minutes?: number; estimated_minutes?: number;
executor: 'codex' | 'gemini' | 'agent' | 'auto'; executor: 'codex' | 'gemini' | 'agent' | 'auto';
// Lifecycle status tracking
lifecycle_status?: {
implemented: boolean;
tested: boolean;
regression_passed: boolean;
accepted: boolean;
committed: boolean;
};
status?: string; status?: string;
priority?: number; priority?: number;
} }
@@ -83,8 +118,13 @@ interface QueueItem {
} }
interface Queue { interface Queue {
id: string; // Queue unique ID: QUE-YYYYMMDD-HHMMSS
name?: string; // Optional queue name
status: 'active' | 'completed' | 'archived' | 'failed';
issue_ids: string[]; // Issues in this queue
queue: QueueItem[]; queue: QueueItem[];
conflicts: any[]; conflicts: any[];
execution_groups?: any[];
_metadata: { _metadata: {
version: string; version: string;
total_tasks: number; total_tasks: number;
@@ -92,10 +132,24 @@ interface Queue {
executing_count: number; executing_count: number;
completed_count: number; completed_count: number;
failed_count: number; failed_count: number;
last_updated: string; created_at: string;
updated_at: string;
}; };
} }
interface QueueIndex {
active_queue_id: string | null;
queues: {
id: string;
status: string;
issue_ids: string[];
total_tasks: number;
completed_tasks: number;
created_at: string;
completed_at?: string;
}[];
}
interface IssueOptions { interface IssueOptions {
status?: string; status?: string;
title?: string; title?: string;
@@ -208,40 +262,121 @@ function generateSolutionId(): string {
return `SOL-${ts}`; return `SOL-${ts}`;
} }
// ============ Queue JSON ============ // ============ Queue Management (Multi-Queue) ============
function readQueue(): Queue { function getQueuesDir(): string {
const path = join(getIssuesDir(), 'queue.json'); return join(getIssuesDir(), 'queues');
}
function ensureQueuesDir(): void {
const dir = getQueuesDir();
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
}
function readQueueIndex(): QueueIndex {
const path = join(getQueuesDir(), 'index.json');
if (!existsSync(path)) { if (!existsSync(path)) {
return { return { active_queue_id: null, queues: [] };
queue: [],
conflicts: [],
_metadata: {
version: '2.0',
total_tasks: 0,
pending_count: 0,
executing_count: 0,
completed_count: 0,
failed_count: 0,
last_updated: new Date().toISOString()
}
};
} }
return JSON.parse(readFileSync(path, 'utf-8')); return JSON.parse(readFileSync(path, 'utf-8'));
} }
function writeQueueIndex(index: QueueIndex): void {
ensureQueuesDir();
writeFileSync(join(getQueuesDir(), 'index.json'), JSON.stringify(index, null, 2), 'utf-8');
}
function generateQueueFileId(): string {
const now = new Date();
const ts = now.toISOString().replace(/[-:T]/g, '').slice(0, 14);
return `QUE-${ts}`;
}
function readQueue(queueId?: string): Queue | null {
const index = readQueueIndex();
const targetId = queueId || index.active_queue_id;
if (!targetId) return null;
const path = join(getQueuesDir(), `${targetId}.json`);
if (!existsSync(path)) return null;
return JSON.parse(readFileSync(path, 'utf-8'));
}
function readActiveQueue(): Queue {
const queue = readQueue();
if (queue) return queue;
// Return empty queue structure if no active queue
return createEmptyQueue();
}
function createEmptyQueue(): Queue {
return {
id: generateQueueFileId(),
status: 'active',
issue_ids: [],
queue: [],
conflicts: [],
_metadata: {
version: '2.0',
total_tasks: 0,
pending_count: 0,
executing_count: 0,
completed_count: 0,
failed_count: 0,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString()
}
};
}
function writeQueue(queue: Queue): void { function writeQueue(queue: Queue): void {
ensureIssuesDir(); ensureQueuesDir();
// Update metadata counts
queue._metadata.total_tasks = queue.queue.length; queue._metadata.total_tasks = queue.queue.length;
queue._metadata.pending_count = queue.queue.filter(q => q.status === 'pending').length; queue._metadata.pending_count = queue.queue.filter(q => q.status === 'pending').length;
queue._metadata.executing_count = queue.queue.filter(q => q.status === 'executing').length; queue._metadata.executing_count = queue.queue.filter(q => q.status === 'executing').length;
queue._metadata.completed_count = queue.queue.filter(q => q.status === 'completed').length; queue._metadata.completed_count = queue.queue.filter(q => q.status === 'completed').length;
queue._metadata.failed_count = queue.queue.filter(q => q.status === 'failed').length; queue._metadata.failed_count = queue.queue.filter(q => q.status === 'failed').length;
queue._metadata.last_updated = new Date().toISOString(); queue._metadata.updated_at = new Date().toISOString();
writeFileSync(join(getIssuesDir(), 'queue.json'), JSON.stringify(queue, null, 2), 'utf-8');
// Write queue file
const path = join(getQueuesDir(), `${queue.id}.json`);
writeFileSync(path, JSON.stringify(queue, null, 2), 'utf-8');
// Update index
const index = readQueueIndex();
const existingIdx = index.queues.findIndex(q => q.id === queue.id);
const indexEntry = {
id: queue.id,
status: queue.status,
issue_ids: queue.issue_ids,
total_tasks: queue._metadata.total_tasks,
completed_tasks: queue._metadata.completed_count,
created_at: queue._metadata.created_at,
completed_at: queue.status === 'completed' ? new Date().toISOString() : undefined
};
if (existingIdx >= 0) {
index.queues[existingIdx] = indexEntry;
} else {
index.queues.unshift(indexEntry);
}
if (queue.status === 'active') {
index.active_queue_id = queue.id;
}
writeQueueIndex(index);
} }
function generateQueueId(queue: Queue): string { function generateQueueItemId(queue: Queue): string {
const maxNum = queue.queue.reduce((max, q) => { const maxNum = queue.queue.reduce((max, q) => {
const match = q.queue_id.match(/^Q-(\d+)$/); const match = q.queue_id.match(/^Q-(\d+)$/);
return match ? Math.max(max, parseInt(match[1])) : max; return match ? Math.max(max, parseInt(match[1])) : max;
@@ -379,17 +514,19 @@ async function listAction(issueId: string | undefined, options: IssueOptions): P
async function statusAction(issueId: string | undefined, options: IssueOptions): Promise<void> { async function statusAction(issueId: string | undefined, options: IssueOptions): Promise<void> {
if (!issueId) { if (!issueId) {
// Show queue status // Show queue status
const queue = readQueue(); const queue = readActiveQueue();
const issues = readIssues(); const issues = readIssues();
const index = readQueueIndex();
if (options.json) { if (options.json) {
console.log(JSON.stringify({ queue: queue._metadata, issues: issues.length }, null, 2)); console.log(JSON.stringify({ queue: queue._metadata, issues: issues.length, queues: index.queues.length }, null, 2));
return; return;
} }
console.log(chalk.bold.cyan('\nSystem Status\n')); console.log(chalk.bold.cyan('\nSystem Status\n'));
console.log(`Issues: ${issues.length}`); console.log(`Issues: ${issues.length}`);
console.log(`Queue: ${queue._metadata.total_tasks} tasks`); console.log(`Queues: ${index.queues.length} (Active: ${index.active_queue_id || 'none'})`);
console.log(`Active Queue: ${queue._metadata.total_tasks} tasks`);
console.log(` Pending: ${queue._metadata.pending_count}`); console.log(` Pending: ${queue._metadata.pending_count}`);
console.log(` Executing: ${queue._metadata.executing_count}`); console.log(` Executing: ${queue._metadata.executing_count}`);
console.log(` Completed: ${queue._metadata.completed_count}`); console.log(` Completed: ${queue._metadata.completed_count}`);
@@ -497,7 +634,20 @@ async function taskAction(issueId: string | undefined, taskId: string | undefine
action: 'Implement', action: 'Implement',
description: options.description || options.title, description: options.description || options.title,
implementation: [], implementation: [],
acceptance: ['Task completed successfully'], test: {
unit: [],
commands: ['npm test']
},
regression: ['npm test'],
acceptance: {
criteria: ['Task completed successfully'],
verification: ['Manual verification']
},
commit: {
type: 'feat',
scope: 'core',
message_template: `feat(core): ${options.title}`
},
depends_on: [], depends_on: [],
executor: (options.executor as any) || 'auto' executor: (options.executor as any) || 'auto'
}; };
@@ -590,13 +740,90 @@ async function bindAction(issueId: string | undefined, solutionId: string | unde
} }
/** /**
* queue - Queue management (list / add) * queue - Queue management (list / add / history)
*/ */
async function queueAction(subAction: string | undefined, issueId: string | undefined, options: IssueOptions): Promise<void> { async function queueAction(subAction: string | undefined, issueId: string | undefined, options: IssueOptions): Promise<void> {
const queue = readQueue(); // List all queues (history)
if (subAction === 'list' || subAction === 'history') {
const index = readQueueIndex();
if (options.json) {
console.log(JSON.stringify(index, null, 2));
return;
}
console.log(chalk.bold.cyan('\nQueue History\n'));
console.log(chalk.gray(`Active: ${index.active_queue_id || 'none'}`));
console.log();
if (index.queues.length === 0) {
console.log(chalk.yellow('No queues found'));
console.log(chalk.gray('Create one: ccw issue queue add <issue-id>'));
return;
}
console.log(chalk.gray('ID'.padEnd(22) + 'Status'.padEnd(12) + 'Tasks'.padEnd(10) + 'Issues'));
console.log(chalk.gray('-'.repeat(70)));
for (const q of index.queues) {
const statusColor = {
'active': chalk.green,
'completed': chalk.cyan,
'archived': chalk.gray,
'failed': chalk.red
}[q.status] || chalk.white;
const marker = q.id === index.active_queue_id ? '→ ' : ' ';
console.log(
marker +
q.id.padEnd(20) +
statusColor(q.status.padEnd(12)) +
`${q.completed_tasks}/${q.total_tasks}`.padEnd(10) +
q.issue_ids.join(', ')
);
}
return;
}
// Switch active queue
if (subAction === 'switch' && issueId) {
const queueId = issueId; // issueId is actually queue ID here
const targetQueue = readQueue(queueId);
if (!targetQueue) {
console.error(chalk.red(`Queue "${queueId}" not found`));
process.exit(1);
}
const index = readQueueIndex();
index.active_queue_id = queueId;
writeQueueIndex(index);
console.log(chalk.green(`✓ Switched to queue ${queueId}`));
return;
}
// Archive current queue
if (subAction === 'archive') {
const queue = readActiveQueue();
if (!queue.id || queue.queue.length === 0) {
console.log(chalk.yellow('No active queue to archive'));
return;
}
queue.status = 'archived';
writeQueue(queue);
const index = readQueueIndex();
index.active_queue_id = null;
writeQueueIndex(index);
console.log(chalk.green(`✓ Archived queue ${queue.id}`));
return;
}
// Add issue tasks to queue
if (subAction === 'add' && issueId) { if (subAction === 'add' && issueId) {
// Add issue tasks to queue
const issue = findIssue(issueId); const issue = findIssue(issueId);
if (!issue) { if (!issue) {
console.error(chalk.red(`Issue "${issueId}" not found`)); console.error(chalk.red(`Issue "${issueId}" not found`));
@@ -610,13 +837,27 @@ async function queueAction(subAction: string | undefined, issueId: string | unde
process.exit(1); process.exit(1);
} }
// Get or create active queue (create new if current is completed/archived)
let queue = readActiveQueue();
const isNewQueue = queue.queue.length === 0 || queue.status !== 'active';
if (queue.status !== 'active') {
// Create new queue if current is not active
queue = createEmptyQueue();
}
// Add issue to queue's issue list
if (!queue.issue_ids.includes(issueId)) {
queue.issue_ids.push(issueId);
}
let added = 0; let added = 0;
for (const task of solution.tasks) { for (const task of solution.tasks) {
const exists = queue.queue.some(q => q.issue_id === issueId && q.task_id === task.id); const exists = queue.queue.some(q => q.issue_id === issueId && q.task_id === task.id);
if (exists) continue; if (exists) continue;
queue.queue.push({ queue.queue.push({
queue_id: generateQueueId(queue), queue_id: generateQueueItemId(queue),
issue_id: issueId, issue_id: issueId,
solution_id: solution.id, solution_id: solution.id,
task_id: task.id, task_id: task.id,
@@ -637,26 +878,35 @@ async function queueAction(subAction: string | undefined, issueId: string | unde
writeQueue(queue); writeQueue(queue);
updateIssue(issueId, { status: 'queued', queued_at: new Date().toISOString() }); updateIssue(issueId, { status: 'queued', queued_at: new Date().toISOString() });
console.log(chalk.green(`✓ Added ${added} tasks to queue from ${solution.id}`)); if (isNewQueue) {
console.log(chalk.green(`✓ Created queue ${queue.id}`));
}
console.log(chalk.green(`✓ Added ${added} tasks from ${solution.id}`));
return; return;
} }
// List queue // Show current queue
const queue = readActiveQueue();
if (options.json) { if (options.json) {
console.log(JSON.stringify(queue, null, 2)); console.log(JSON.stringify(queue, null, 2));
return; return;
} }
console.log(chalk.bold.cyan('\nExecution Queue\n')); console.log(chalk.bold.cyan('\nActive Queue\n'));
console.log(chalk.gray(`Total: ${queue._metadata.total_tasks} | Pending: ${queue._metadata.pending_count} | Executing: ${queue._metadata.executing_count} | Completed: ${queue._metadata.completed_count}`));
console.log();
if (queue.queue.length === 0) { if (!queue.id || queue.queue.length === 0) {
console.log(chalk.yellow('Queue is empty')); console.log(chalk.yellow('No active queue'));
console.log(chalk.gray('Add tasks: ccw issue queue add <issue-id>')); console.log(chalk.gray('Create one: ccw issue queue add <issue-id>'));
console.log(chalk.gray('Or list history: ccw issue queue list'));
return; return;
} }
console.log(chalk.gray(`Queue: ${queue.id}`));
console.log(chalk.gray(`Issues: ${queue.issue_ids.join(', ')}`));
console.log(chalk.gray(`Total: ${queue._metadata.total_tasks} | Pending: ${queue._metadata.pending_count} | Executing: ${queue._metadata.executing_count} | Completed: ${queue._metadata.completed_count}`));
console.log();
console.log(chalk.gray('QueueID'.padEnd(10) + 'Issue'.padEnd(15) + 'Task'.padEnd(8) + 'Status'.padEnd(12) + 'Executor')); console.log(chalk.gray('QueueID'.padEnd(10) + 'Issue'.padEnd(15) + 'Task'.padEnd(8) + 'Status'.padEnd(12) + 'Executor'));
console.log(chalk.gray('-'.repeat(60))); console.log(chalk.gray('-'.repeat(60)));
@@ -684,7 +934,7 @@ async function queueAction(subAction: string | undefined, issueId: string | unde
* next - Get next ready task for execution (JSON output) * next - Get next ready task for execution (JSON output)
*/ */
async function nextAction(options: IssueOptions): Promise<void> { async function nextAction(options: IssueOptions): Promise<void> {
const queue = readQueue(); const queue = readActiveQueue();
// Find ready tasks // Find ready tasks
const readyTasks = queue.queue.filter(item => { const readyTasks = queue.queue.filter(item => {
@@ -749,7 +999,7 @@ async function doneAction(queueId: string | undefined, options: IssueOptions): P
process.exit(1); process.exit(1);
} }
const queue = readQueue(); const queue = readActiveQueue();
const idx = queue.queue.findIndex(q => q.queue_id === queueId); const idx = queue.queue.findIndex(q => q.queue_id === queueId);
if (idx === -1) { if (idx === -1) {
@@ -771,31 +1021,49 @@ async function doneAction(queueId: string | undefined, options: IssueOptions): P
} }
} }
writeQueue(queue);
// Check if all issue tasks are complete // Check if all issue tasks are complete
const issueId = queue.queue[idx].issue_id; const issueId = queue.queue[idx].issue_id;
const issueTasks = queue.queue.filter(q => q.issue_id === issueId); const issueTasks = queue.queue.filter(q => q.issue_id === issueId);
const allComplete = issueTasks.every(q => q.status === 'completed'); const allIssueComplete = issueTasks.every(q => q.status === 'completed');
const anyFailed = issueTasks.some(q => q.status === 'failed'); const anyIssueFailed = issueTasks.some(q => q.status === 'failed');
if (allComplete) { if (allIssueComplete) {
updateIssue(issueId, { status: 'completed', completed_at: new Date().toISOString() }); updateIssue(issueId, { status: 'completed', completed_at: new Date().toISOString() });
console.log(chalk.green(`${queueId} completed`)); console.log(chalk.green(`${queueId} completed`));
console.log(chalk.green(`✓ Issue ${issueId} completed (all tasks done)`)); console.log(chalk.green(`✓ Issue ${issueId} completed (all tasks done)`));
} else if (anyFailed) { } else if (anyIssueFailed) {
updateIssue(issueId, { status: 'failed' }); updateIssue(issueId, { status: 'failed' });
console.log(chalk.red(`${queueId} failed`)); console.log(chalk.red(`${queueId} failed`));
} else { } else {
console.log(isFail ? chalk.red(`${queueId} failed`) : chalk.green(`${queueId} completed`)); console.log(isFail ? chalk.red(`${queueId} failed`) : chalk.green(`${queueId} completed`));
} }
// Check if entire queue is complete
const allQueueComplete = queue.queue.every(q => q.status === 'completed');
const anyQueueFailed = queue.queue.some(q => q.status === 'failed');
if (allQueueComplete) {
queue.status = 'completed';
console.log(chalk.green(`\n✓ Queue ${queue.id} completed (all tasks done)`));
} else if (anyQueueFailed && queue.queue.every(q => q.status === 'completed' || q.status === 'failed')) {
queue.status = 'failed';
console.log(chalk.yellow(`\n⚠ Queue ${queue.id} has failed tasks`));
}
writeQueue(queue);
} }
/** /**
* retry - Retry failed tasks * retry - Retry failed tasks
*/ */
async function retryAction(issueId: string | undefined, options: IssueOptions): Promise<void> { async function retryAction(issueId: string | undefined, options: IssueOptions): Promise<void> {
const queue = readQueue(); const queue = readActiveQueue();
if (!queue.id || queue.queue.length === 0) {
console.log(chalk.yellow('No active queue'));
return;
}
let updated = 0; let updated = 0;
for (const item of queue.queue) { for (const item of queue.queue) {
@@ -815,6 +1083,11 @@ async function retryAction(issueId: string | undefined, options: IssueOptions):
return; return;
} }
// Reset queue status if it was failed
if (queue.status === 'failed') {
queue.status = 'active';
}
writeQueue(queue); writeQueue(queue);
if (issueId) { if (issueId) {
@@ -873,7 +1146,7 @@ export async function issueCommand(
await doneAction(argsArray[0], { ...options, fail: true }); await doneAction(argsArray[0], { ...options, fail: true });
break; break;
default: default:
console.log(chalk.bold.cyan('\nCCW Issue Management (v2.0 - Unified JSONL)\n')); console.log(chalk.bold.cyan('\nCCW Issue Management (v3.0 - Multi-Queue + Lifecycle)\n'));
console.log(chalk.bold('Core Commands:')); console.log(chalk.bold('Core Commands:'));
console.log(chalk.gray(' init <issue-id> Initialize new issue')); console.log(chalk.gray(' init <issue-id> Initialize new issue'));
console.log(chalk.gray(' list [issue-id] List issues or tasks')); console.log(chalk.gray(' list [issue-id] List issues or tasks'));
@@ -882,8 +1155,11 @@ export async function issueCommand(
console.log(chalk.gray(' bind <issue-id> [sol-id] Bind solution (--solution <path> to register)')); console.log(chalk.gray(' bind <issue-id> [sol-id] Bind solution (--solution <path> to register)'));
console.log(); console.log();
console.log(chalk.bold('Queue Commands:')); console.log(chalk.bold('Queue Commands:'));
console.log(chalk.gray(' queue [list] Show execution queue')); console.log(chalk.gray(' queue Show active queue'));
console.log(chalk.gray(' queue add <issue-id> Add bound solution tasks to queue')); console.log(chalk.gray(' queue list List all queues (history)'));
console.log(chalk.gray(' queue add <issue-id> Add issue to active queue (or create new)'));
console.log(chalk.gray(' queue switch <queue-id> Switch active queue'));
console.log(chalk.gray(' queue archive Archive current queue'));
console.log(chalk.gray(' retry [issue-id] Retry failed tasks')); console.log(chalk.gray(' retry [issue-id] Retry failed tasks'));
console.log(); console.log();
console.log(chalk.bold('Execution Endpoints:')); console.log(chalk.bold('Execution Endpoints:'));
@@ -902,6 +1178,7 @@ export async function issueCommand(
console.log(chalk.bold('Storage:')); console.log(chalk.bold('Storage:'));
console.log(chalk.gray(' .workflow/issues/issues.jsonl All issues')); console.log(chalk.gray(' .workflow/issues/issues.jsonl All issues'));
console.log(chalk.gray(' .workflow/issues/solutions/*.jsonl Solutions per issue')); console.log(chalk.gray(' .workflow/issues/solutions/*.jsonl Solutions per issue'));
console.log(chalk.gray(' .workflow/issues/queue.json Execution queue')); console.log(chalk.gray(' .workflow/issues/queues/ Queue files (multi-queue)'));
console.log(chalk.gray(' .workflow/issues/queues/index.json Queue index'));
} }
} }

View File

@@ -72,21 +72,68 @@ function writeSolutionsJsonl(issuesDir: string, issueId: string, solutions: any[
} }
function readQueue(issuesDir: string) { function readQueue(issuesDir: string) {
const queuePath = join(issuesDir, 'queue.json'); // Try new multi-queue structure first
if (!existsSync(queuePath)) { const queuesDir = join(issuesDir, 'queues');
return { queue: [], conflicts: [], execution_groups: [], _metadata: { version: '1.0', total_tasks: 0 } }; const indexPath = join(queuesDir, 'index.json');
if (existsSync(indexPath)) {
try {
const index = JSON.parse(readFileSync(indexPath, 'utf8'));
const activeQueueId = index.active_queue_id;
if (activeQueueId) {
const queueFilePath = join(queuesDir, `${activeQueueId}.json`);
if (existsSync(queueFilePath)) {
return JSON.parse(readFileSync(queueFilePath, 'utf8'));
}
}
} catch {
// Fall through to legacy check
}
} }
try {
return JSON.parse(readFileSync(queuePath, 'utf8')); // Fallback to legacy queue.json
} catch { const legacyQueuePath = join(issuesDir, 'queue.json');
return { queue: [], conflicts: [], execution_groups: [], _metadata: { version: '1.0', total_tasks: 0 } }; if (existsSync(legacyQueuePath)) {
try {
return JSON.parse(readFileSync(legacyQueuePath, 'utf8'));
} catch {
// Return empty queue
}
} }
return { queue: [], conflicts: [], execution_groups: [], _metadata: { version: '1.0', total_tasks: 0 } };
} }
function writeQueue(issuesDir: string, queue: any) { function writeQueue(issuesDir: string, queue: any) {
if (!existsSync(issuesDir)) mkdirSync(issuesDir, { recursive: true }); if (!existsSync(issuesDir)) mkdirSync(issuesDir, { recursive: true });
queue._metadata = { ...queue._metadata, last_updated: new Date().toISOString(), total_tasks: queue.queue?.length || 0 }; queue._metadata = { ...queue._metadata, updated_at: new Date().toISOString(), total_tasks: queue.queue?.length || 0 };
writeFileSync(join(issuesDir, 'queue.json'), JSON.stringify(queue, null, 2));
// Check if using new multi-queue structure
const queuesDir = join(issuesDir, 'queues');
const indexPath = join(queuesDir, 'index.json');
if (existsSync(indexPath) && queue.id) {
// Write to new structure
const queueFilePath = join(queuesDir, `${queue.id}.json`);
writeFileSync(queueFilePath, JSON.stringify(queue, null, 2));
// Update index metadata
try {
const index = JSON.parse(readFileSync(indexPath, 'utf8'));
const queueEntry = index.queues?.find((q: any) => q.id === queue.id);
if (queueEntry) {
queueEntry.total_tasks = queue.queue?.length || 0;
queueEntry.completed_tasks = queue.queue?.filter((i: any) => i.status === 'completed').length || 0;
writeFileSync(indexPath, JSON.stringify(index, null, 2));
}
} catch {
// Ignore index update errors
}
} else {
// Fallback to legacy queue.json
writeFileSync(join(issuesDir, 'queue.json'), JSON.stringify(queue, null, 2));
}
} }
function getIssueDetail(issuesDir: string, issueId: string) { function getIssueDetail(issuesDir: string, issueId: string) {

View File

@@ -276,9 +276,105 @@
color: hsl(var(--muted-foreground)); color: hsl(var(--muted-foreground));
} }
.queue-empty svg { .queue-empty svg,
.queue-empty > i {
margin-bottom: 1rem; margin-bottom: 1rem;
opacity: 0.5; opacity: 0.5;
color: hsl(var(--muted-foreground));
}
.queue-empty-container {
display: flex;
align-items: center;
justify-content: center;
min-height: 300px;
}
.queue-empty-title {
font-size: 1.125rem;
font-weight: 600;
color: hsl(var(--foreground));
margin-bottom: 0.5rem;
}
.queue-empty-hint {
font-size: 0.875rem;
color: hsl(var(--muted-foreground));
margin-bottom: 1.5rem;
}
.queue-create-btn {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.625rem 1.25rem;
background: hsl(var(--primary));
color: hsl(var(--primary-foreground));
border: none;
border-radius: 0.375rem;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all 0.15s ease;
}
.queue-create-btn:hover {
background: hsl(var(--primary) / 0.9);
transform: translateY(-1px);
}
/* Queue Toolbar */
.queue-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.75rem 1rem;
background: hsl(var(--muted) / 0.3);
border: 1px solid hsl(var(--border));
border-radius: 0.5rem;
}
.queue-stats {
display: flex;
align-items: center;
gap: 0.5rem;
}
.queue-actions {
display: flex;
align-items: center;
gap: 0.5rem;
}
/* Command Box */
.command-option {
margin-bottom: 0.75rem;
}
.command-box {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem;
background: hsl(var(--muted) / 0.5);
border: 1px solid hsl(var(--border));
border-radius: 0.375rem;
}
.command-text {
flex: 1;
font-family: var(--font-mono);
font-size: 0.875rem;
color: hsl(var(--foreground));
word-break: break-all;
}
.command-info {
padding: 0.75rem;
background: hsl(var(--primary) / 0.05);
border-radius: 0.375rem;
border-left: 3px solid hsl(var(--primary));
} }
/* Issue ID */ /* Issue ID */
@@ -1349,6 +1445,20 @@
cursor: pointer; cursor: pointer;
} }
/* Input with action button */
.input-with-action {
display: flex;
gap: 0.5rem;
}
.input-with-action input {
flex: 1;
}
.input-with-action .btn-icon {
flex-shrink: 0;
}
/* ========================================== /* ==========================================
BUTTON STYLES BUTTON STYLES
========================================== */ ========================================== */
@@ -1759,3 +1869,676 @@
-webkit-overflow-scrolling: touch; -webkit-overflow-scrolling: touch;
} }
} }
/* ==========================================
SOLUTION DETAIL MODAL
========================================== */
.solution-modal {
position: fixed;
inset: 0;
z-index: 1100;
display: flex;
align-items: center;
justify-content: center;
}
.solution-modal.hidden {
display: none;
}
.solution-modal-backdrop {
position: absolute;
inset: 0;
background: hsl(var(--foreground) / 0.6);
animation: fadeIn 0.15s ease-out;
}
.solution-modal-content {
position: relative;
width: 90%;
max-width: 720px;
max-height: 85vh;
background: hsl(var(--background));
border: 1px solid hsl(var(--border));
border-radius: 0.75rem;
box-shadow: 0 25px 50px hsl(var(--foreground) / 0.2);
display: flex;
flex-direction: column;
animation: modalSlideIn 0.2s ease-out;
}
.solution-modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.25rem;
border-bottom: 1px solid hsl(var(--border));
flex-shrink: 0;
}
.solution-modal-title h3 {
font-size: 1.125rem;
font-weight: 600;
color: hsl(var(--foreground));
margin-top: 0.25rem;
}
.solution-modal-actions {
display: flex;
align-items: center;
gap: 0.5rem;
}
.solution-modal-body {
flex: 1;
overflow-y: auto;
padding: 1.25rem;
}
/* Solution Overview Stats */
.solution-overview {
display: flex;
gap: 1.5rem;
padding: 1rem;
background: hsl(var(--muted) / 0.3);
border-radius: 0.5rem;
margin-bottom: 1rem;
}
.solution-stat {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.25rem;
}
.solution-stat-value {
font-size: 1.5rem;
font-weight: 700;
color: hsl(var(--foreground));
}
.solution-stat-label {
font-size: 0.75rem;
color: hsl(var(--muted-foreground));
text-transform: uppercase;
letter-spacing: 0.05em;
}
/* Solution Detail Section */
.solution-detail-section {
margin-bottom: 1.5rem;
}
.solution-detail-section:last-child {
margin-bottom: 0;
}
.solution-detail-section-title {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
font-weight: 600;
color: hsl(var(--foreground));
margin-bottom: 0.75rem;
}
/* Solution Tasks Detail */
.solution-tasks-detail {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.solution-task-card {
background: hsl(var(--card));
border: 1px solid hsl(var(--border));
border-radius: 0.5rem;
overflow: hidden;
}
.solution-task-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem;
background: hsl(var(--muted) / 0.3);
cursor: pointer;
transition: background 0.15s ease;
}
.solution-task-header:hover {
background: hsl(var(--muted) / 0.5);
}
.solution-task-info {
display: flex;
align-items: center;
gap: 0.5rem;
}
.solution-task-index {
font-size: 0.75rem;
font-weight: 600;
color: hsl(var(--muted-foreground));
min-width: 1.5rem;
}
.solution-task-id {
font-size: 0.75rem;
color: hsl(var(--muted-foreground));
}
.task-expand-icon {
transition: transform 0.2s ease;
color: hsl(var(--muted-foreground));
}
.solution-task-title {
padding: 0.75rem 1rem;
font-size: 0.875rem;
font-weight: 500;
color: hsl(var(--foreground));
border-top: 1px solid hsl(var(--border) / 0.5);
}
.solution-task-details {
padding: 0.75rem 1rem;
border-top: 1px solid hsl(var(--border));
background: hsl(var(--muted) / 0.15);
}
.solution-task-scope {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.75rem;
padding: 0.5rem;
background: hsl(var(--primary) / 0.1);
border-radius: 0.375rem;
}
.solution-task-scope-label {
font-size: 0.75rem;
font-weight: 500;
color: hsl(var(--muted-foreground));
}
.solution-task-subtitle {
display: flex;
align-items: center;
gap: 0.375rem;
font-size: 0.75rem;
font-weight: 600;
color: hsl(var(--muted-foreground));
margin-bottom: 0.5rem;
text-transform: uppercase;
letter-spacing: 0.025em;
}
.solution-task-mod-points,
.solution-task-impl-steps,
.solution-task-acceptance,
.solution-task-deps {
margin-bottom: 0.75rem;
}
.solution-task-list,
.solution-impl-list,
.solution-acceptance-list {
margin: 0;
padding-left: 1.25rem;
font-size: 0.8125rem;
color: hsl(var(--foreground));
}
.solution-task-list li,
.solution-impl-list li,
.solution-acceptance-list li {
margin: 0.25rem 0;
}
.solution-mod-point {
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.mod-point-file {
color: hsl(var(--primary));
font-size: 0.8125rem;
}
.mod-point-change {
font-size: 0.75rem;
color: hsl(var(--muted-foreground));
margin-left: 0.5rem;
}
.solution-deps-list {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
}
.solution-dep-tag {
padding: 0.125rem 0.5rem;
background: hsl(var(--muted));
color: hsl(var(--muted-foreground));
border-radius: 0.25rem;
font-size: 0.75rem;
}
/* ==========================================
LIFECYCLE PHASE BADGES
========================================== */
.phase-badge {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.25rem;
height: 1.25rem;
border-radius: 9999px;
font-size: 0.6875rem;
font-weight: 600;
margin-right: 0.25rem;
}
.phase-badge.phase-1 {
background: hsl(217 91% 60% / 0.2);
color: hsl(217 91% 60%);
}
.phase-badge.phase-2 {
background: hsl(262 83% 58% / 0.2);
color: hsl(262 83% 58%);
}
.phase-badge.phase-3 {
background: hsl(25 95% 53% / 0.2);
color: hsl(25 95% 53%);
}
.phase-badge.phase-4 {
background: hsl(142 71% 45% / 0.2);
color: hsl(142 71% 45%);
}
.phase-badge.phase-5 {
background: hsl(199 89% 48% / 0.2);
color: hsl(199 89% 48%);
}
/* ==========================================
QUEUE STATS GRID
========================================== */
.queue-stats-grid {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 0.75rem;
}
@media (max-width: 768px) {
.queue-stats-grid {
grid-template-columns: repeat(3, 1fr);
}
}
@media (max-width: 480px) {
.queue-stats-grid {
grid-template-columns: repeat(2, 1fr);
}
}
.queue-stat-card {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 0.75rem 1rem;
background: hsl(var(--muted) / 0.3);
border: 1px solid hsl(var(--border));
border-radius: 0.5rem;
text-align: center;
}
.queue-stat-card .queue-stat-value {
font-size: 1.5rem;
font-weight: 700;
color: hsl(var(--foreground));
line-height: 1.2;
}
.queue-stat-card .queue-stat-label {
font-size: 0.75rem;
color: hsl(var(--muted-foreground));
text-transform: uppercase;
letter-spacing: 0.025em;
margin-top: 0.25rem;
}
.queue-stat-card.pending {
border-color: hsl(var(--muted-foreground) / 0.3);
}
.queue-stat-card.pending .queue-stat-value {
color: hsl(var(--muted-foreground));
}
.queue-stat-card.executing {
border-color: hsl(45 93% 47% / 0.5);
background: hsl(45 93% 47% / 0.05);
}
.queue-stat-card.executing .queue-stat-value {
color: hsl(45 93% 47%);
}
.queue-stat-card.completed {
border-color: hsl(var(--success) / 0.5);
background: hsl(var(--success) / 0.05);
}
.queue-stat-card.completed .queue-stat-value {
color: hsl(var(--success));
}
.queue-stat-card.failed {
border-color: hsl(var(--destructive) / 0.5);
background: hsl(var(--destructive) / 0.05);
}
.queue-stat-card.failed .queue-stat-value {
color: hsl(var(--destructive));
}
/* ==========================================
QUEUE INFO CARDS
========================================== */
.queue-info-card {
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.queue-info-label {
font-size: 0.6875rem;
color: hsl(var(--muted-foreground));
text-transform: uppercase;
letter-spacing: 0.05em;
}
.queue-info-value {
font-size: 0.875rem;
color: hsl(var(--foreground));
}
/* Queue Status Badge */
.queue-status-badge {
display: inline-flex;
align-items: center;
padding: 0.125rem 0.5rem;
border-radius: 9999px;
font-size: 0.6875rem;
font-weight: 500;
text-transform: uppercase;
}
.queue-status-badge.active {
background: hsl(217 91% 60% / 0.15);
color: hsl(217 91% 60%);
}
.queue-status-badge.completed {
background: hsl(var(--success) / 0.15);
color: hsl(var(--success));
}
.queue-status-badge.failed {
background: hsl(var(--destructive) / 0.15);
color: hsl(var(--destructive));
}
.queue-status-badge.archived {
background: hsl(var(--muted));
color: hsl(var(--muted-foreground));
}
/* ==========================================
SOLUTION TASK SECTIONS
========================================== */
.solution-task-section {
margin-bottom: 0.75rem;
padding-bottom: 0.75rem;
border-bottom: 1px solid hsl(var(--border) / 0.3);
}
.solution-task-section:last-child {
margin-bottom: 0;
padding-bottom: 0;
border-bottom: none;
}
/* ==========================================
TEST SECTION STYLES
========================================== */
.test-subsection,
.acceptance-subsection {
margin-bottom: 0.5rem;
}
.test-subsection:last-child,
.acceptance-subsection:last-child {
margin-bottom: 0;
}
.test-label,
.acceptance-label {
display: block;
font-size: 0.6875rem;
font-weight: 500;
color: hsl(var(--muted-foreground));
text-transform: uppercase;
letter-spacing: 0.025em;
margin-bottom: 0.25rem;
}
.test-list {
margin: 0;
padding-left: 1.25rem;
font-size: 0.8125rem;
color: hsl(var(--foreground));
}
.test-list li {
margin: 0.125rem 0;
}
.test-commands,
.verification-commands {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.test-command,
.verification-command {
display: block;
padding: 0.375rem 0.625rem;
background: hsl(var(--muted) / 0.5);
border: 1px solid hsl(var(--border));
border-radius: 0.25rem;
font-family: var(--font-mono);
font-size: 0.75rem;
color: hsl(var(--foreground));
word-break: break-all;
}
.coverage-target {
font-size: 0.6875rem;
font-weight: 400;
color: hsl(var(--muted-foreground));
margin-left: 0.25rem;
}
/* ==========================================
COMMIT INFO STYLES
========================================== */
.commit-info {
background: hsl(var(--muted) / 0.3);
border: 1px solid hsl(var(--border));
border-radius: 0.375rem;
padding: 0.625rem;
}
.commit-type {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.commit-type-badge {
display: inline-flex;
align-items: center;
padding: 0.125rem 0.5rem;
border-radius: 0.25rem;
font-size: 0.75rem;
font-weight: 600;
text-transform: lowercase;
}
.commit-type-badge.feat {
background: hsl(142 71% 45% / 0.15);
color: hsl(142 71% 45%);
}
.commit-type-badge.fix {
background: hsl(0 84% 60% / 0.15);
color: hsl(0 84% 60%);
}
.commit-type-badge.refactor {
background: hsl(262 83% 58% / 0.15);
color: hsl(262 83% 58%);
}
.commit-type-badge.test {
background: hsl(199 89% 48% / 0.15);
color: hsl(199 89% 48%);
}
.commit-type-badge.docs {
background: hsl(45 93% 47% / 0.15);
color: hsl(45 93% 47%);
}
.commit-type-badge.chore {
background: hsl(var(--muted));
color: hsl(var(--muted-foreground));
}
.commit-scope {
font-size: 0.75rem;
color: hsl(var(--muted-foreground));
}
.commit-breaking {
display: inline-flex;
align-items: center;
padding: 0.125rem 0.375rem;
background: hsl(var(--destructive) / 0.15);
color: hsl(var(--destructive));
border-radius: 0.25rem;
font-size: 0.625rem;
font-weight: 700;
letter-spacing: 0.05em;
}
.commit-message {
margin: 0;
padding: 0.5rem;
background: hsl(var(--background));
border: 1px solid hsl(var(--border));
border-radius: 0.25rem;
font-family: var(--font-mono);
font-size: 0.75rem;
color: hsl(var(--foreground));
white-space: pre-wrap;
word-break: break-word;
}
/* Modification Point Target */
.mod-point-target {
font-size: 0.75rem;
color: hsl(var(--primary));
font-family: var(--font-mono);
}
/* JSON Toggle */
.solution-json-toggle {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
padding: 0.75rem 1rem;
background: hsl(var(--muted) / 0.3);
border: 1px solid hsl(var(--border));
border-radius: 0.375rem;
color: hsl(var(--muted-foreground));
font-size: 0.875rem;
cursor: pointer;
transition: all 0.15s ease;
}
.solution-json-toggle:hover {
background: hsl(var(--muted) / 0.5);
color: hsl(var(--foreground));
}
.solution-json-content {
margin-top: 0.5rem;
border: 1px solid hsl(var(--border));
border-radius: 0.375rem;
overflow: hidden;
}
.solution-json-pre {
margin: 0;
padding: 1rem;
background: hsl(var(--muted) / 0.3);
font-family: var(--font-mono);
font-size: 0.75rem;
line-height: 1.5;
color: hsl(var(--foreground));
overflow-x: auto;
white-space: pre-wrap;
word-break: break-all;
max-height: 300px;
overflow-y: auto;
}
/* Responsive Solution Modal */
@media (max-width: 640px) {
.solution-modal-content {
max-height: 95vh;
margin: 0.5rem;
}
.solution-overview {
flex-wrap: wrap;
justify-content: center;
}
.solution-stat {
min-width: 80px;
}
}

View File

@@ -1772,6 +1772,45 @@ const i18n = {
'issues.created': 'Issue created successfully', 'issues.created': 'Issue created successfully',
'issues.confirmDelete': 'Are you sure you want to delete this issue?', 'issues.confirmDelete': 'Are you sure you want to delete this issue?',
'issues.deleted': 'Issue deleted', 'issues.deleted': 'Issue deleted',
'issues.idAutoGenerated': 'Auto-generated',
'issues.regenerateId': 'Regenerate ID',
// Solution detail
'issues.solutionDetail': 'Solution Details',
'issues.bind': 'Bind',
'issues.unbind': 'Unbind',
'issues.bound': 'Bound',
'issues.totalTasks': 'Total Tasks',
'issues.bindStatus': 'Bind Status',
'issues.createdAt': 'Created',
'issues.taskList': 'Task List',
'issues.noTasks': 'No tasks in this solution',
'issues.noSolutions': 'No solutions',
'issues.viewJson': 'View Raw JSON',
'issues.scope': 'Scope',
'issues.modificationPoints': 'Modification Points',
'issues.implementationSteps': 'Implementation Steps',
'issues.acceptanceCriteria': 'Acceptance Criteria',
'issues.dependencies': 'Dependencies',
'issues.solutionBound': 'Solution bound successfully',
'issues.solutionUnbound': 'Solution unbound',
// Queue operations
'issues.queueEmptyHint': 'Generate execution queue from bound solutions',
'issues.createQueue': 'Create Queue',
'issues.regenerate': 'Regenerate',
'issues.regenerateQueue': 'Regenerate Queue',
'issues.refreshQueue': 'Refresh',
'issues.executionGroups': 'groups',
'issues.totalItems': 'items',
'issues.queueRefreshed': 'Queue refreshed',
'issues.confirmCreateQueue': 'This will execute /issue:queue command via Claude Code CLI to generate execution queue from bound solutions.\n\nContinue?',
'issues.creatingQueue': 'Creating execution queue...',
'issues.queueExecutionStarted': 'Queue generation started',
'issues.queueCreated': 'Queue created successfully',
'issues.queueCreationFailed': 'Queue creation failed',
'issues.queueCommandHint': 'Run one of the following commands in your terminal to generate the execution queue from bound solutions:',
'issues.queueCommandInfo': 'After running the command, click "Refresh" to see the updated queue.',
'issues.alternative': 'Alternative',
'issues.refreshAfter': 'Refresh Queue',
// issue.* keys (legacy) // issue.* keys (legacy)
'issue.viewIssues': 'Issues', 'issue.viewIssues': 'Issues',
'issue.viewQueue': 'Queue', 'issue.viewQueue': 'Queue',
@@ -3595,6 +3634,45 @@ const i18n = {
'issues.created': '议题创建成功', 'issues.created': '议题创建成功',
'issues.confirmDelete': '确定要删除此议题吗?', 'issues.confirmDelete': '确定要删除此议题吗?',
'issues.deleted': '议题已删除', 'issues.deleted': '议题已删除',
'issues.idAutoGenerated': '自动生成',
'issues.regenerateId': '重新生成ID',
// Solution detail
'issues.solutionDetail': '解决方案详情',
'issues.bind': '绑定',
'issues.unbind': '解绑',
'issues.bound': '已绑定',
'issues.totalTasks': '任务总数',
'issues.bindStatus': '绑定状态',
'issues.createdAt': '创建时间',
'issues.taskList': '任务列表',
'issues.noTasks': '此解决方案无任务',
'issues.noSolutions': '暂无解决方案',
'issues.viewJson': '查看原始JSON',
'issues.scope': '作用域',
'issues.modificationPoints': '修改点',
'issues.implementationSteps': '实现步骤',
'issues.acceptanceCriteria': '验收标准',
'issues.dependencies': '依赖项',
'issues.solutionBound': '解决方案已绑定',
'issues.solutionUnbound': '解决方案已解绑',
// Queue operations
'issues.queueEmptyHint': '从绑定的解决方案生成执行队列',
'issues.createQueue': '创建队列',
'issues.regenerate': '重新生成',
'issues.regenerateQueue': '重新生成队列',
'issues.refreshQueue': '刷新',
'issues.executionGroups': '个执行组',
'issues.totalItems': '个任务',
'issues.queueRefreshed': '队列已刷新',
'issues.confirmCreateQueue': '这将通过 Claude Code CLI 执行 /issue:queue 命令,从绑定的解决方案生成执行队列。\n\n是否继续',
'issues.creatingQueue': '正在创建执行队列...',
'issues.queueExecutionStarted': '队列生成已启动',
'issues.queueCreated': '队列创建成功',
'issues.queueCreationFailed': '队列创建失败',
'issues.queueCommandHint': '在终端中运行以下命令之一,从绑定的解决方案生成执行队列:',
'issues.queueCommandInfo': '运行命令后,点击"刷新"查看更新后的队列。',
'issues.alternative': '或者',
'issues.refreshAfter': '刷新队列',
// issue.* keys (legacy) // issue.* keys (legacy)
'issue.viewIssues': '议题', 'issue.viewIssues': '议题',
'issue.viewQueue': '队列', 'issue.viewQueue': '队列',

View File

@@ -168,16 +168,22 @@ async function loadAvailableSkills() {
if (!response.ok) throw new Error('Failed to load skills'); if (!response.ok) throw new Error('Failed to load skills');
const data = await response.json(); const data = await response.json();
// Combine project and user skills (API returns { projectSkills: [], userSkills: [] })
const allSkills = [
...(data.projectSkills || []).map(s => ({ ...s, scope: 'project' })),
...(data.userSkills || []).map(s => ({ ...s, scope: 'user' }))
];
const container = document.getElementById('skill-discovery-skill-context'); const container = document.getElementById('skill-discovery-skill-context');
if (container && data.skills) { if (container) {
if (data.skills.length === 0) { if (allSkills.length === 0) {
container.innerHTML = ` container.innerHTML = `
<span class="font-mono bg-muted px-1.5 py-0.5 rounded">${t('hook.wizard.availableSkills')}</span> <span class="font-mono bg-muted px-1.5 py-0.5 rounded">${t('hook.wizard.availableSkills')}</span>
<span class="text-muted-foreground ml-2">${t('hook.wizard.noSkillsFound').split('.')[0]}</span> <span class="text-muted-foreground ml-2">${t('hook.wizard.noSkillsFound').split('.')[0]}</span>
`; `;
} else { } else {
const skillBadges = data.skills.map(skill => ` const skillBadges = allSkills.map(skill => `
<span class="px-2 py-0.5 bg-emerald-500/10 text-emerald-500 rounded" title="${escapeHtml(skill.description)}">${escapeHtml(skill.name)}</span> <span class="px-2 py-0.5 bg-emerald-500/10 text-emerald-500 rounded" title="${escapeHtml(skill.description || '')}">${escapeHtml(skill.name)}</span>
`).join(''); `).join('');
container.innerHTML = ` container.innerHTML = `
<span class="font-mono bg-muted px-1.5 py-0.5 rounded">${t('hook.wizard.availableSkills')}</span> <span class="font-mono bg-muted px-1.5 py-0.5 rounded">${t('hook.wizard.availableSkills')}</span>
@@ -187,7 +193,7 @@ async function loadAvailableSkills() {
} }
// Store skills for wizard use // Store skills for wizard use
window.availableSkills = data.skills || []; window.availableSkills = allSkills;
} catch (err) { } catch (err) {
console.error('Failed to load skills:', err); console.error('Failed to load skills:', err);
const container = document.getElementById('skill-discovery-skill-context'); const container = document.getElementById('skill-discovery-skill-context');

View File

@@ -9,6 +9,7 @@ var issueData = {
queue: { queue: [], conflicts: [], execution_groups: [], grouped_items: {} }, queue: { queue: [], conflicts: [], execution_groups: [], grouped_items: {} },
selectedIssue: null, selectedIssue: null,
selectedSolution: null, selectedSolution: null,
selectedSolutionIssueId: null,
statusFilter: 'all', statusFilter: 'all',
searchQuery: '', searchQuery: '',
viewMode: 'issues' // 'issues' | 'queue' viewMode: 'issues' // 'issues' | 'queue'
@@ -148,6 +149,31 @@ function renderIssueView() {
<!-- Detail Panel --> <!-- Detail Panel -->
<div id="issueDetailPanel" class="issue-detail-panel hidden"></div> <div id="issueDetailPanel" class="issue-detail-panel hidden"></div>
<!-- Solution Detail Modal -->
<div id="solutionDetailModal" class="solution-modal hidden">
<div class="solution-modal-backdrop" onclick="closeSolutionDetail()"></div>
<div class="solution-modal-content">
<div class="solution-modal-header">
<div class="solution-modal-title">
<span id="solutionDetailId" class="font-mono text-sm text-muted-foreground"></span>
<h3 id="solutionDetailTitle">${t('issues.solutionDetail') || 'Solution Details'}</h3>
</div>
<div class="solution-modal-actions">
<button id="solutionBindBtn" class="btn-secondary" onclick="toggleSolutionBind()">
<i data-lucide="link" class="w-4 h-4"></i>
<span>${t('issues.bind') || 'Bind'}</span>
</button>
<button class="btn-icon" onclick="closeSolutionDetail()">
<i data-lucide="x" class="w-5 h-5"></i>
</button>
</div>
</div>
<div class="solution-modal-body" id="solutionDetailBody">
<!-- Content will be rendered dynamically -->
</div>
</div>
</div>
<!-- Create Issue Modal --> <!-- Create Issue Modal -->
<div id="createIssueModal" class="issue-modal hidden"> <div id="createIssueModal" class="issue-modal hidden">
<div class="issue-modal-backdrop" onclick="hideCreateIssueModal()"></div> <div class="issue-modal-backdrop" onclick="hideCreateIssueModal()"></div>
@@ -161,7 +187,12 @@ function renderIssueView() {
<div class="issue-modal-body"> <div class="issue-modal-body">
<div class="form-group"> <div class="form-group">
<label>${t('issues.issueId') || 'Issue ID'}</label> <label>${t('issues.issueId') || 'Issue ID'}</label>
<input type="text" id="newIssueId" placeholder="e.g., GH-123 or TASK-001" /> <div class="input-with-action">
<input type="text" id="newIssueId" placeholder="${t('issues.idAutoGenerated') || 'Auto-generated'}" />
<button type="button" class="btn-icon" onclick="regenerateIssueId()" title="${t('issues.regenerateId') || 'Regenerate ID'}">
<i data-lucide="refresh-cw" class="w-4 h-4"></i>
</button>
</div>
</div> </div>
<div class="form-group"> <div class="form-group">
<label>${t('issues.issueTitle') || 'Title'}</label> <label>${t('issues.issueTitle') || 'Title'}</label>
@@ -329,20 +360,129 @@ function filterIssuesByStatus(status) {
// ========== Queue Section ========== // ========== Queue Section ==========
function renderQueueSection() { function renderQueueSection() {
const queue = issueData.queue; const queue = issueData.queue;
const groups = queue.execution_groups || []; const queueItems = queue.queue || [];
const groupedItems = queue.grouped_items || {}; const metadata = queue._metadata || {};
if (groups.length === 0 && (!queue.queue || queue.queue.length === 0)) { // Check if queue is empty
if (queueItems.length === 0) {
return ` return `
<div class="queue-empty"> <div class="queue-empty-container">
<i data-lucide="git-branch" class="w-12 h-12 text-muted-foreground mb-4"></i> <div class="queue-empty">
<p class="text-muted-foreground">${t('issues.queueEmpty') || 'Queue is empty'}</p> <i data-lucide="git-branch" class="w-16 h-16"></i>
<p class="text-sm text-muted-foreground mt-2">Run /issue:queue to form execution queue</p> <p class="queue-empty-title">${t('issues.queueEmpty') || 'Queue is empty'}</p>
<p class="queue-empty-hint">${t('issues.queueEmptyHint') || 'Generate execution queue from bound solutions'}</p>
<button class="queue-create-btn" onclick="createExecutionQueue()">
<i data-lucide="play" class="w-4 h-4"></i>
<span>${t('issues.createQueue') || 'Create Queue'}</span>
</button>
</div>
</div> </div>
`; `;
} }
// Group items by execution_group or treat all as single group
const groups = queue.execution_groups || [];
let groupedItems = queue.grouped_items || {};
// If no execution_groups, create a default grouping from queue items
if (groups.length === 0 && queueItems.length > 0) {
const groupMap = {};
queueItems.forEach(item => {
const groupId = item.execution_group || 'default';
if (!groupMap[groupId]) {
groupMap[groupId] = [];
}
groupMap[groupId].push(item);
});
// Create synthetic groups
const syntheticGroups = Object.keys(groupMap).map(groupId => ({
id: groupId,
type: 'sequential',
task_count: groupMap[groupId].length
}));
return `
<!-- Queue Header -->
<div class="queue-toolbar mb-4">
<div class="queue-stats">
<div class="queue-info-card">
<span class="queue-info-label">${t('issues.queueId') || 'Queue ID'}</span>
<span class="queue-info-value font-mono text-sm">${queue.id || 'N/A'}</span>
</div>
<div class="queue-info-card">
<span class="queue-info-label">${t('issues.status') || 'Status'}</span>
<span class="queue-status-badge ${queue.status || ''}">${queue.status || 'unknown'}</span>
</div>
<div class="queue-info-card">
<span class="queue-info-label">${t('issues.issues') || 'Issues'}</span>
<span class="queue-info-value">${(queue.issue_ids || []).join(', ') || 'N/A'}</span>
</div>
</div>
<div class="queue-actions">
<button class="btn-secondary" onclick="refreshQueue()" title="${t('issues.refreshQueue') || 'Refresh'}">
<i data-lucide="refresh-cw" class="w-4 h-4"></i>
</button>
<button class="btn-secondary" onclick="createExecutionQueue()" title="${t('issues.regenerateQueue') || 'Regenerate Queue'}">
<i data-lucide="rotate-cw" class="w-4 h-4"></i>
<span>${t('issues.regenerate') || 'Regenerate'}</span>
</button>
</div>
</div>
<!-- Queue Stats -->
<div class="queue-stats-grid mb-4">
<div class="queue-stat-card">
<span class="queue-stat-value">${metadata.total_tasks || queueItems.length}</span>
<span class="queue-stat-label">${t('issues.totalTasks') || 'Total'}</span>
</div>
<div class="queue-stat-card pending">
<span class="queue-stat-value">${metadata.pending_count || queueItems.filter(i => i.status === 'pending').length}</span>
<span class="queue-stat-label">${t('issues.pending') || 'Pending'}</span>
</div>
<div class="queue-stat-card executing">
<span class="queue-stat-value">${metadata.executing_count || queueItems.filter(i => i.status === 'executing').length}</span>
<span class="queue-stat-label">${t('issues.executing') || 'Executing'}</span>
</div>
<div class="queue-stat-card completed">
<span class="queue-stat-value">${metadata.completed_count || queueItems.filter(i => i.status === 'completed').length}</span>
<span class="queue-stat-label">${t('issues.completed') || 'Completed'}</span>
</div>
<div class="queue-stat-card failed">
<span class="queue-stat-value">${metadata.failed_count || queueItems.filter(i => i.status === 'failed').length}</span>
<span class="queue-stat-label">${t('issues.failed') || 'Failed'}</span>
</div>
</div>
<!-- Queue Items -->
<div class="queue-timeline">
${syntheticGroups.map(group => renderQueueGroup(group, groupMap[group.id] || [])).join('')}
</div>
${queue.conflicts && queue.conflicts.length > 0 ? renderConflictsSection(queue.conflicts) : ''}
`;
}
return ` return `
<!-- Queue Toolbar -->
<div class="queue-toolbar mb-4">
<div class="queue-stats">
<span class="text-sm text-muted-foreground">
${groups.length} ${t('issues.executionGroups') || 'groups'} ·
${queueItems.length} ${t('issues.totalItems') || 'items'}
</span>
</div>
<div class="queue-actions">
<button class="btn-secondary" onclick="refreshQueue()" title="${t('issues.refreshQueue') || 'Refresh'}">
<i data-lucide="refresh-cw" class="w-4 h-4"></i>
</button>
<button class="btn-secondary" onclick="createExecutionQueue()" title="${t('issues.regenerateQueue') || 'Regenerate Queue'}">
<i data-lucide="rotate-cw" class="w-4 h-4"></i>
<span>${t('issues.regenerate') || 'Regenerate'}</span>
</button>
</div>
</div>
<div class="queue-info mb-4"> <div class="queue-info mb-4">
<p class="text-sm text-muted-foreground"> <p class="text-sm text-muted-foreground">
<i data-lucide="info" class="w-4 h-4 inline mr-1"></i> <i data-lucide="info" class="w-4 h-4 inline mr-1"></i>
@@ -605,24 +745,16 @@ function renderIssueDetailPanel(issue) {
<div class="detail-section"> <div class="detail-section">
<label class="detail-label">${t('issues.solutions') || 'Solutions'} (${issue.solutions?.length || 0})</label> <label class="detail-label">${t('issues.solutions') || 'Solutions'} (${issue.solutions?.length || 0})</label>
<div class="solutions-list"> <div class="solutions-list">
${(issue.solutions || []).map(sol => ` ${(issue.solutions || []).length > 0 ? (issue.solutions || []).map(sol => `
<div class="solution-item ${sol.is_bound ? 'bound' : ''}" onclick="toggleSolutionExpand('${sol.id}')"> <div class="solution-item ${sol.is_bound ? 'bound' : ''}" onclick="openSolutionDetail('${issue.id}', '${sol.id}')">
<div class="solution-header"> <div class="solution-header">
<span class="solution-id font-mono text-xs">${sol.id}</span> <span class="solution-id font-mono text-xs">${sol.id}</span>
${sol.is_bound ? '<span class="solution-bound-badge">Bound</span>' : ''} ${sol.is_bound ? '<span class="solution-bound-badge">' + (t('issues.bound') || 'Bound') + '</span>' : ''}
<span class="solution-tasks text-xs">${sol.tasks?.length || 0} tasks</span> <span class="solution-tasks text-xs">${sol.tasks?.length || 0} ${t('issues.tasks') || 'tasks'}</span>
</div> <i data-lucide="chevron-right" class="w-4 h-4 ml-auto text-muted-foreground"></i>
<div class="solution-tasks-list hidden" id="solution-${sol.id}">
${(sol.tasks || []).map(task => `
<div class="task-item">
<span class="task-id font-mono">${task.id}</span>
<span class="task-action ${task.action?.toLowerCase() || ''}">${task.action || 'Unknown'}</span>
<span class="task-title">${task.title || ''}</span>
</div>
`).join('')}
</div> </div>
</div> </div>
`).join('') || '<p class="text-sm text-muted-foreground">No solutions</p>'} `).join('') : '<p class="text-sm text-muted-foreground">' + (t('issues.noSolutions') || 'No solutions') + '</p>'}
</div> </div>
</div> </div>
@@ -630,7 +762,7 @@ function renderIssueDetailPanel(issue) {
<div class="detail-section"> <div class="detail-section">
<label class="detail-label">${t('issues.tasks') || 'Tasks'} (${issue.tasks?.length || 0})</label> <label class="detail-label">${t('issues.tasks') || 'Tasks'} (${issue.tasks?.length || 0})</label>
<div class="tasks-list"> <div class="tasks-list">
${(issue.tasks || []).map(task => ` ${(issue.tasks || []).length > 0 ? (issue.tasks || []).map(task => `
<div class="task-item-detail"> <div class="task-item-detail">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<span class="font-mono text-sm">${task.id}</span> <span class="font-mono text-sm">${task.id}</span>
@@ -642,7 +774,7 @@ function renderIssueDetailPanel(issue) {
</div> </div>
<p class="task-title-detail">${task.title || task.description || ''}</p> <p class="task-title-detail">${task.title || task.description || ''}</p>
</div> </div>
`).join('') || '<p class="text-sm text-muted-foreground">No tasks</p>'} `).join('') : '<p class="text-sm text-muted-foreground">' + (t('issues.noTasks') || 'No tasks') + '</p>'}
</div> </div>
</div> </div>
</div> </div>
@@ -666,6 +798,353 @@ function toggleSolutionExpand(solId) {
} }
} }
// ========== Solution Detail Modal ==========
function openSolutionDetail(issueId, solutionId) {
const issue = issueData.selectedIssue || issueData.issues.find(i => i.id === issueId);
if (!issue) return;
const solution = issue.solutions?.find(s => s.id === solutionId);
if (!solution) return;
issueData.selectedSolution = solution;
issueData.selectedSolutionIssueId = issueId;
const modal = document.getElementById('solutionDetailModal');
if (modal) {
modal.classList.remove('hidden');
renderSolutionDetail(solution);
lucide.createIcons();
}
}
function closeSolutionDetail() {
const modal = document.getElementById('solutionDetailModal');
if (modal) {
modal.classList.add('hidden');
}
issueData.selectedSolution = null;
issueData.selectedSolutionIssueId = null;
}
function renderSolutionDetail(solution) {
const idEl = document.getElementById('solutionDetailId');
const bodyEl = document.getElementById('solutionDetailBody');
const bindBtn = document.getElementById('solutionBindBtn');
if (idEl) {
idEl.textContent = solution.id;
}
// Update bind button state
if (bindBtn) {
if (solution.is_bound) {
bindBtn.innerHTML = `<i data-lucide="unlink" class="w-4 h-4"></i><span>${t('issues.unbind') || 'Unbind'}</span>`;
bindBtn.classList.remove('btn-secondary');
bindBtn.classList.add('btn-primary');
} else {
bindBtn.innerHTML = `<i data-lucide="link" class="w-4 h-4"></i><span>${t('issues.bind') || 'Bind'}</span>`;
bindBtn.classList.remove('btn-primary');
bindBtn.classList.add('btn-secondary');
}
}
if (!bodyEl) return;
const tasks = solution.tasks || [];
bodyEl.innerHTML = `
<!-- Solution Overview -->
<div class="solution-detail-section">
<div class="solution-overview">
<div class="solution-stat">
<span class="solution-stat-value">${tasks.length}</span>
<span class="solution-stat-label">${t('issues.totalTasks') || 'Total Tasks'}</span>
</div>
<div class="solution-stat">
<span class="solution-stat-value">${solution.is_bound ? '✓' : '—'}</span>
<span class="solution-stat-label">${t('issues.bindStatus') || 'Bind Status'}</span>
</div>
<div class="solution-stat">
<span class="solution-stat-value">${solution.created_at ? new Date(solution.created_at).toLocaleDateString() : '—'}</span>
<span class="solution-stat-label">${t('issues.createdAt') || 'Created'}</span>
</div>
</div>
</div>
<!-- Tasks List -->
<div class="solution-detail-section">
<h4 class="solution-detail-section-title">
<i data-lucide="list-checks" class="w-4 h-4"></i>
${t('issues.taskList') || 'Task List'}
</h4>
<div class="solution-tasks-detail">
${tasks.length === 0 ? `
<p class="text-sm text-muted-foreground text-center py-4">${t('issues.noTasks') || 'No tasks in this solution'}</p>
` : tasks.map((task, index) => renderSolutionTask(task, index)).join('')}
</div>
</div>
<!-- Raw JSON (collapsible) -->
<div class="solution-detail-section">
<button class="solution-json-toggle" onclick="toggleSolutionJson()">
<i data-lucide="code" class="w-4 h-4"></i>
<span>${t('issues.viewJson') || 'View Raw JSON'}</span>
<i data-lucide="chevron-down" class="w-4 h-4 ml-auto"></i>
</button>
<div id="solutionJsonContent" class="solution-json-content hidden">
<pre class="solution-json-pre">${escapeHtml(JSON.stringify(solution, null, 2))}</pre>
</div>
</div>
`;
lucide.createIcons();
}
function renderSolutionTask(task, index) {
const actionClass = (task.action || 'unknown').toLowerCase();
const modPoints = task.modification_points || [];
// Support both old and new field names
const implSteps = task.implementation || task.implementation_steps || [];
const acceptance = task.acceptance || task.acceptance_criteria || [];
const testInfo = task.test || {};
const regression = task.regression || [];
const commitInfo = task.commit || {};
const dependsOn = task.depends_on || task.dependencies || [];
// Handle acceptance as object or array
const acceptanceCriteria = Array.isArray(acceptance) ? acceptance : (acceptance.criteria || []);
const acceptanceVerification = acceptance.verification || [];
return `
<div class="solution-task-card">
<div class="solution-task-header" onclick="toggleTaskExpand(${index})">
<div class="solution-task-info">
<span class="solution-task-index">#${index + 1}</span>
<span class="solution-task-id font-mono">${task.id || ''}</span>
<span class="task-action-badge ${actionClass}">${task.action || 'Unknown'}</span>
</div>
<i data-lucide="chevron-down" class="w-4 h-4 task-expand-icon" id="taskExpandIcon${index}"></i>
</div>
<div class="solution-task-title">${task.title || task.description || 'No title'}</div>
<div class="solution-task-details hidden" id="taskDetails${index}">
${task.scope ? `
<div class="solution-task-scope">
<span class="solution-task-scope-label">${t('issues.scope') || 'Scope'}:</span>
<span class="font-mono text-sm">${task.scope}</span>
</div>
` : ''}
<!-- Phase 1: Implementation -->
${implSteps.length > 0 ? `
<div class="solution-task-section">
<h5 class="solution-task-subtitle">
<i data-lucide="code" class="w-3.5 h-3.5"></i>
<span class="phase-badge phase-1">1</span>
${t('issues.implementation') || 'Implementation'}
</h5>
<ol class="solution-impl-list">
${implSteps.map(step => `<li>${typeof step === 'string' ? step : step.description || JSON.stringify(step)}</li>`).join('')}
</ol>
</div>
` : ''}
${modPoints.length > 0 ? `
<div class="solution-task-section">
<h5 class="solution-task-subtitle">
<i data-lucide="file-edit" class="w-3.5 h-3.5"></i>
${t('issues.modificationPoints') || 'Modification Points'}
</h5>
<ul class="solution-task-list">
${modPoints.map(mp => `
<li class="solution-mod-point">
<span class="mod-point-file font-mono">${mp.file || mp}</span>
${mp.target ? `<span class="mod-point-target">→ ${mp.target}</span>` : ''}
${mp.change ? `<span class="mod-point-change">${mp.change}</span>` : ''}
</li>
`).join('')}
</ul>
</div>
` : ''}
<!-- Phase 2: Test -->
${(testInfo.unit?.length > 0 || testInfo.commands?.length > 0) ? `
<div class="solution-task-section">
<h5 class="solution-task-subtitle">
<i data-lucide="flask-conical" class="w-3.5 h-3.5"></i>
<span class="phase-badge phase-2">2</span>
${t('issues.test') || 'Test'}
${testInfo.coverage_target ? `<span class="coverage-target">(${testInfo.coverage_target}% coverage)</span>` : ''}
</h5>
${testInfo.unit?.length > 0 ? `
<div class="test-subsection">
<span class="test-label">${t('issues.unitTests') || 'Unit Tests'}:</span>
<ul class="test-list">
${testInfo.unit.map(t => `<li>${t}</li>`).join('')}
</ul>
</div>
` : ''}
${testInfo.integration?.length > 0 ? `
<div class="test-subsection">
<span class="test-label">${t('issues.integrationTests') || 'Integration'}:</span>
<ul class="test-list">
${testInfo.integration.map(t => `<li>${t}</li>`).join('')}
</ul>
</div>
` : ''}
${testInfo.commands?.length > 0 ? `
<div class="test-subsection">
<span class="test-label">${t('issues.commands') || 'Commands'}:</span>
<div class="test-commands">
${testInfo.commands.map(cmd => `<code class="test-command">${cmd}</code>`).join('')}
</div>
</div>
` : ''}
</div>
` : ''}
<!-- Phase 3: Regression -->
${regression.length > 0 ? `
<div class="solution-task-section">
<h5 class="solution-task-subtitle">
<i data-lucide="rotate-ccw" class="w-3.5 h-3.5"></i>
<span class="phase-badge phase-3">3</span>
${t('issues.regression') || 'Regression'}
</h5>
<div class="test-commands">
${regression.map(cmd => `<code class="test-command">${cmd}</code>`).join('')}
</div>
</div>
` : ''}
<!-- Phase 4: Acceptance -->
${acceptanceCriteria.length > 0 ? `
<div class="solution-task-section">
<h5 class="solution-task-subtitle">
<i data-lucide="check-circle" class="w-3.5 h-3.5"></i>
<span class="phase-badge phase-4">4</span>
${t('issues.acceptance') || 'Acceptance'}
</h5>
<div class="acceptance-subsection">
<span class="acceptance-label">${t('issues.criteria') || 'Criteria'}:</span>
<ul class="solution-acceptance-list">
${acceptanceCriteria.map(ac => `<li>${typeof ac === 'string' ? ac : ac.description || JSON.stringify(ac)}</li>`).join('')}
</ul>
</div>
${acceptanceVerification.length > 0 ? `
<div class="acceptance-subsection">
<span class="acceptance-label">${t('issues.verification') || 'Verification'}:</span>
<div class="verification-commands">
${acceptanceVerification.map(v => `<code class="verification-command">${v}</code>`).join('')}
</div>
</div>
` : ''}
</div>
` : ''}
<!-- Phase 5: Commit -->
${commitInfo.type ? `
<div class="solution-task-section">
<h5 class="solution-task-subtitle">
<i data-lucide="git-commit" class="w-3.5 h-3.5"></i>
<span class="phase-badge phase-5">5</span>
${t('issues.commit') || 'Commit'}
</h5>
<div class="commit-info">
<div class="commit-type">
<span class="commit-type-badge ${commitInfo.type}">${commitInfo.type}</span>
<span class="commit-scope">(${commitInfo.scope || 'core'})</span>
${commitInfo.breaking ? '<span class="commit-breaking">BREAKING</span>' : ''}
</div>
${commitInfo.message_template ? `
<pre class="commit-message">${commitInfo.message_template}</pre>
` : ''}
</div>
</div>
` : ''}
<!-- Dependencies -->
${dependsOn.length > 0 ? `
<div class="solution-task-section">
<h5 class="solution-task-subtitle">
<i data-lucide="git-branch" class="w-3.5 h-3.5"></i>
${t('issues.dependencies') || 'Dependencies'}
</h5>
<div class="solution-deps-list">
${dependsOn.map(dep => `<span class="solution-dep-tag font-mono">${dep}</span>`).join('')}
</div>
</div>
` : ''}
</div>
</div>
`;
}
function toggleTaskExpand(index) {
const details = document.getElementById('taskDetails' + index);
const icon = document.getElementById('taskExpandIcon' + index);
if (details) {
details.classList.toggle('hidden');
}
if (icon) {
icon.style.transform = details?.classList.contains('hidden') ? '' : 'rotate(180deg)';
}
}
function toggleSolutionJson() {
const content = document.getElementById('solutionJsonContent');
if (content) {
content.classList.toggle('hidden');
}
}
async function toggleSolutionBind() {
const solution = issueData.selectedSolution;
const issueId = issueData.selectedSolutionIssueId;
if (!solution || !issueId) return;
const action = solution.is_bound ? 'unbind' : 'bind';
try {
const response = await fetch('/api/issues/' + encodeURIComponent(issueId) + '?path=' + encodeURIComponent(projectPath), {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
bound_solution_id: action === 'bind' ? solution.id : null
})
});
if (!response.ok) throw new Error('Failed to ' + action);
showNotification(action === 'bind' ? (t('issues.solutionBound') || 'Solution bound') : (t('issues.solutionUnbound') || 'Solution unbound'), 'success');
// Refresh data
await loadIssueData();
const detail = await loadIssueDetail(issueId);
if (detail) {
issueData.selectedIssue = detail;
// Update solution reference
const updatedSolution = detail.solutions?.find(s => s.id === solution.id);
if (updatedSolution) {
issueData.selectedSolution = updatedSolution;
renderSolutionDetail(updatedSolution);
}
renderIssueDetailPanel(detail);
}
} catch (err) {
console.error('Failed to ' + action + ' solution:', err);
showNotification('Failed to ' + action + ' solution', 'error');
}
}
// Helper: escape HTML
function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function openQueueItemDetail(queueId) { function openQueueItemDetail(queueId) {
const item = issueData.queue.queue?.find(q => q.queue_id === queueId); const item = issueData.queue.queue?.find(q => q.queue_id === queueId);
if (item) { if (item) {
@@ -807,18 +1286,58 @@ function clearIssueSearch() {
} }
// ========== Create Issue Modal ========== // ========== Create Issue Modal ==========
function generateIssueId() {
// Generate unique ID: ISSUE-YYYYMMDD-XXX format
const now = new Date();
const dateStr = now.getFullYear().toString() +
String(now.getMonth() + 1).padStart(2, '0') +
String(now.getDate()).padStart(2, '0');
// Find existing IDs with same date prefix
const prefix = 'ISSUE-' + dateStr + '-';
const existingIds = (issueData.issues || [])
.map(i => i.id)
.filter(id => id.startsWith(prefix));
// Get next sequence number
let maxSeq = 0;
existingIds.forEach(id => {
const seqStr = id.replace(prefix, '');
const seq = parseInt(seqStr, 10);
if (!isNaN(seq) && seq > maxSeq) {
maxSeq = seq;
}
});
return prefix + String(maxSeq + 1).padStart(3, '0');
}
function showCreateIssueModal() { function showCreateIssueModal() {
const modal = document.getElementById('createIssueModal'); const modal = document.getElementById('createIssueModal');
if (modal) { if (modal) {
modal.classList.remove('hidden'); modal.classList.remove('hidden');
// Auto-generate issue ID
const idInput = document.getElementById('newIssueId');
if (idInput) {
idInput.value = generateIssueId();
}
lucide.createIcons(); lucide.createIcons();
// Focus on first input // Focus on title input instead of ID
setTimeout(() => { setTimeout(() => {
document.getElementById('newIssueId')?.focus(); document.getElementById('newIssueTitle')?.focus();
}, 100); }, 100);
} }
} }
function regenerateIssueId() {
const idInput = document.getElementById('newIssueId');
if (idInput) {
idInput.value = generateIssueId();
}
}
function hideCreateIssueModal() { function hideCreateIssueModal() {
const modal = document.getElementById('createIssueModal'); const modal = document.getElementById('createIssueModal');
if (modal) { if (modal) {
@@ -913,3 +1432,115 @@ async function deleteIssue(issueId) {
showNotification('Failed to delete issue', 'error'); showNotification('Failed to delete issue', 'error');
} }
} }
// ========== Queue Operations ==========
async function refreshQueue() {
try {
await loadQueueData();
renderIssueView();
showNotification(t('issues.queueRefreshed') || 'Queue refreshed', 'success');
} catch (err) {
showNotification('Failed to refresh queue', 'error');
}
}
function createExecutionQueue() {
showQueueCommandModal();
}
function showQueueCommandModal() {
// Create modal if not exists
let modal = document.getElementById('queueCommandModal');
if (!modal) {
modal = document.createElement('div');
modal.id = 'queueCommandModal';
modal.className = 'issue-modal';
document.body.appendChild(modal);
}
const command = 'claude /issue:queue';
const altCommand = 'ccw issue queue';
modal.innerHTML = `
<div class="issue-modal-backdrop" onclick="hideQueueCommandModal()"></div>
<div class="issue-modal-content" style="max-width: 560px;">
<div class="issue-modal-header">
<h3>${t('issues.createQueue') || 'Create Execution Queue'}</h3>
<button class="btn-icon" onclick="hideQueueCommandModal()">
<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">
${t('issues.queueCommandHint') || 'Run one of the following commands in your terminal to generate the execution queue from bound solutions:'}
</p>
<div class="command-option mb-3">
<label class="text-xs font-medium text-muted-foreground mb-1 block">
<i data-lucide="terminal" class="w-3 h-3 inline mr-1"></i>
Claude Code CLI
</label>
<div class="command-box">
<code class="command-text">${command}</code>
<button class="btn-icon" onclick="copyCommand('${command}')" title="${t('common.copy') || 'Copy'}">
<i data-lucide="copy" class="w-4 h-4"></i>
</button>
</div>
</div>
<div class="command-option">
<label class="text-xs font-medium text-muted-foreground mb-1 block">
<i data-lucide="terminal" class="w-3 h-3 inline mr-1"></i>
CCW CLI (${t('issues.alternative') || 'Alternative'})
</label>
<div class="command-box">
<code class="command-text">${altCommand}</code>
<button class="btn-icon" onclick="copyCommand('${altCommand}')" title="${t('common.copy') || 'Copy'}">
<i data-lucide="copy" class="w-4 h-4"></i>
</button>
</div>
</div>
<div class="command-info mt-4">
<p class="text-xs text-muted-foreground">
<i data-lucide="info" class="w-3 h-3 inline mr-1"></i>
${t('issues.queueCommandInfo') || 'After running the command, click "Refresh" to see the updated queue.'}
</p>
</div>
</div>
<div class="issue-modal-footer">
<button class="btn-secondary" onclick="hideQueueCommandModal()">${t('common.close') || 'Close'}</button>
<button class="btn-primary" onclick="hideQueueCommandModal(); refreshQueue();">
<i data-lucide="refresh-cw" class="w-4 h-4"></i>
${t('issues.refreshAfter') || 'Refresh Queue'}
</button>
</div>
</div>
`;
modal.classList.remove('hidden');
lucide.createIcons();
}
function hideQueueCommandModal() {
const modal = document.getElementById('queueCommandModal');
if (modal) {
modal.classList.add('hidden');
}
}
function copyCommand(command) {
navigator.clipboard.writeText(command).then(() => {
showNotification(t('common.copied') || 'Copied to clipboard', 'success');
}).catch(err => {
console.error('Failed to copy:', err);
// Fallback: select text
const textArea = document.createElement('textarea');
textArea.value = command;
document.body.appendChild(textArea);
textArea.select();
document.execCommand('copy');
document.body.removeChild(textArea);
showNotification(t('common.copied') || 'Copied to clipboard', 'success');
});
}