fix(kanban): remove error column and add backend JSON repair (#1143)

* fix(kanban): remove error column and add backend JSON repair

Remove the Error column from the Kanban board since tasks with parsing
errors should be automatically repaired by the backend rather than
displayed in an error state.

Backend changes:
- Add atomic writes to subtask.py and qa.py using write_json_atomic()
- Add retry logic with auto_fix_plan() when JSONDecodeError occurs
- Enhance auto_fix.py with JSON syntax repair for trailing commas,
  truncated JSON, and unquoted status values

Frontend changes:
- Remove 'error' from TaskStatus type and TASK_STATUS_COLUMNS
- Remove TaskErrorInfo interface and errorInfo field from Task
- Update KanbanBoard.tsx, TaskCard.tsx, project-store.ts, task-store.ts
- Remove error-related i18n translations (en/fr)
- Remove .column-error CSS class
- Skip tasks with parse errors instead of displaying them

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(pr-review): address PR review findings for JSON repair and error handling

- auto_fix.py: Use stack-based bracket closing for correct nested structure repair
- auto_fix.py: Strip string contents before counting brackets to avoid false matches
- auto_fix.py: Use write_json_atomic for safe file writes
- auto_fix.py: Log exceptions instead of silently swallowing
- qa.py: Extract _apply_qa_update helper to reduce duplication (DRY)
- qa.py: Log retry failures instead of silent pass
- subtask.py: Extract _update_subtask_in_plan helper to reduce duplication (DRY)
- subtask.py: Log retry failures instead of silent pass
- project-store.ts: Show tasks with JSON errors in UI instead of silently skipping

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* style: apply ruff formatting

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(pr-review): address remaining PR review findings

- qa.py: Return retry_err instead of original e when retry fails
- subtask.py: Return retry_err instead of original e when retry fails
- subtask.py: Add else branch for subtask-not-found after auto-fix
- auto_fix.py: Replace print() with logging.info() for consistency

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: address PR review findings for JSON error handling

- Fix regex patterns in auto_fix.py to handle escaped quotes properly
  (lines 59, 61 now use (?:[^"\\]|\\.)* consistent with line 38)
- Fix unquoted status regex to require quoted key before colon,
  preventing false matches inside JSON string values
- Fix isIncompleteHumanReview to return false for reviewReason='errors'
  since JSON error tasks are intentionally in human_review without subtasks
- Add i18n support for JSON error messages:
  - Add translation keys to en/errors.json and fr/errors.json
  - Use markers in project-store.ts for renderer i18n resolution
  - Update TaskCard, TaskHeader, TaskMetadata to use translations

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: address final PR review findings

- Fix Python CI: correct ruff formatting issue in auto_fix.py
- NEW-001: Add 1MB input size limit to JSON repair for defensive safety
- NEW-003: Preserve original JSONDecodeError context in QA retry messages
- CMT-001: Centralize JSON_ERROR_PREFIX and JSON_ERROR_TITLE_SUFFIX
  constants in shared/constants/task.ts and import in all 4 files

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: StillKnotKnown <192589389+StillKnotKnown@users.noreply.github.com>
Co-authored-by: Test User <test@example.com>
This commit is contained in:
Andy
2026-01-16 15:13:03 +01:00
committed by GitHub
parent 4b43f074e8
commit 51f67c5dd9
16 changed files with 410 additions and 154 deletions
+95 -27
View File
@@ -6,10 +6,14 @@ Tools for managing QA status and sign-off in implementation_plan.json.
"""
import json
import logging
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from core.file_utils import write_json_atomic
from spec.validate_pkg.auto_fix import auto_fix_plan
try:
from claude_agent_sdk import tool
@@ -19,6 +23,53 @@ except ImportError:
tool = None
def _apply_qa_update(
plan: dict[str, Any],
status: str,
issues: list[Any],
tests_passed: dict[str, Any],
) -> int:
"""
Apply QA update to the plan and return the new QA session number.
Args:
plan: The implementation plan dict
status: QA status (pending, in_review, approved, rejected, fixes_applied)
issues: List of issues found
tests_passed: Dict of test results
Returns:
The new QA session number
"""
# Get current QA session number
current_qa = plan.get("qa_signoff", {})
qa_session = current_qa.get("qa_session", 0)
if status in ["in_review", "rejected"]:
qa_session += 1
plan["qa_signoff"] = {
"status": status,
"qa_session": qa_session,
"issues_found": issues,
"tests_passed": tests_passed,
"timestamp": datetime.now(timezone.utc).isoformat(),
"ready_for_qa_revalidation": status == "fixes_applied",
}
# Update plan status to match QA result
# This ensures the UI shows the correct column after QA
if status == "approved":
plan["status"] = "human_review"
plan["planStatus"] = "review"
elif status == "rejected":
plan["status"] = "human_review"
plan["planStatus"] = "review"
plan["last_updated"] = datetime.now(timezone.utc).isoformat()
return qa_session
def create_qa_tools(spec_dir: Path, project_dir: Path) -> list:
"""
Create QA management tools.
@@ -92,34 +143,10 @@ def create_qa_tools(spec_dir: Path, project_dir: Path) -> list:
with open(plan_file) as f:
plan = json.load(f)
# Get current QA session number
current_qa = plan.get("qa_signoff", {})
qa_session = current_qa.get("qa_session", 0)
if status in ["in_review", "rejected"]:
qa_session += 1
qa_session = _apply_qa_update(plan, status, issues, tests_passed)
plan["qa_signoff"] = {
"status": status,
"qa_session": qa_session,
"issues_found": issues,
"tests_passed": tests_passed,
"timestamp": datetime.now(timezone.utc).isoformat(),
"ready_for_qa_revalidation": status == "fixes_applied",
}
# Update plan status to match QA result
# This ensures the UI shows the correct column after QA
if status == "approved":
plan["status"] = "human_review"
plan["planStatus"] = "review"
elif status == "rejected":
plan["status"] = "human_review"
plan["planStatus"] = "review"
plan["last_updated"] = datetime.now(timezone.utc).isoformat()
with open(plan_file, "w") as f:
json.dump(plan, f, indent=2)
# Use atomic write to prevent file corruption
write_json_atomic(plan_file, plan, indent=2)
return {
"content": [
@@ -130,6 +157,47 @@ def create_qa_tools(spec_dir: Path, project_dir: Path) -> list:
]
}
except json.JSONDecodeError as e:
# Attempt to auto-fix the plan and retry
if auto_fix_plan(spec_dir):
# Retry after fix
try:
with open(plan_file) as f:
plan = json.load(f)
qa_session = _apply_qa_update(plan, status, issues, tests_passed)
write_json_atomic(plan_file, plan, indent=2)
return {
"content": [
{
"type": "text",
"text": f"Updated QA status to '{status}' (session {qa_session}) (after auto-fix)",
}
]
}
except Exception as retry_err:
logging.warning(
f"QA update retry failed after auto-fix: {retry_err} (original error: {e})"
)
return {
"content": [
{
"type": "text",
"text": f"Error: QA update failed after auto-fix: {retry_err} (original JSON error: {e})",
}
]
}
return {
"content": [
{
"type": "text",
"text": f"Error: Invalid JSON in implementation_plan.json: {e}",
}
]
}
except Exception as e:
return {
"content": [{"type": "text", "text": f"Error updating QA status: {e}"}]
+87 -18
View File
@@ -6,10 +6,14 @@ Tools for managing subtask status in implementation_plan.json.
"""
import json
import logging
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from core.file_utils import write_json_atomic
from spec.validate_pkg.auto_fix import auto_fix_plan
try:
from claude_agent_sdk import tool
@@ -19,6 +23,43 @@ except ImportError:
tool = None
def _update_subtask_in_plan(
plan: dict[str, Any],
subtask_id: str,
status: str,
notes: str,
) -> bool:
"""
Update a subtask in the plan.
Args:
plan: The implementation plan dict
subtask_id: ID of the subtask to update
status: New status (pending, in_progress, completed, failed)
notes: Optional notes to add
Returns:
True if subtask was found and updated, False otherwise
"""
subtask_found = False
for phase in plan.get("phases", []):
for subtask in phase.get("subtasks", []):
if subtask.get("id") == subtask_id:
subtask["status"] = status
if notes:
subtask["notes"] = notes
subtask["updated_at"] = datetime.now(timezone.utc).isoformat()
subtask_found = True
break
if subtask_found:
break
if subtask_found:
plan["last_updated"] = datetime.now(timezone.utc).isoformat()
return subtask_found
def create_subtask_tools(spec_dir: Path, project_dir: Path) -> list:
"""
Create subtask management tools.
@@ -75,19 +116,7 @@ def create_subtask_tools(spec_dir: Path, project_dir: Path) -> list:
with open(plan_file) as f:
plan = json.load(f)
# Find and update the subtask
subtask_found = False
for phase in plan.get("phases", []):
for subtask in phase.get("subtasks", []):
if subtask.get("id") == subtask_id:
subtask["status"] = status
if notes:
subtask["notes"] = notes
subtask["updated_at"] = datetime.now(timezone.utc).isoformat()
subtask_found = True
break
if subtask_found:
break
subtask_found = _update_subtask_in_plan(plan, subtask_id, status, notes)
if not subtask_found:
return {
@@ -99,11 +128,8 @@ def create_subtask_tools(spec_dir: Path, project_dir: Path) -> list:
]
}
# Update plan metadata
plan["last_updated"] = datetime.now(timezone.utc).isoformat()
with open(plan_file, "w") as f:
json.dump(plan, f, indent=2)
# Use atomic write to prevent file corruption
write_json_atomic(plan_file, plan, indent=2)
return {
"content": [
@@ -115,6 +141,49 @@ def create_subtask_tools(spec_dir: Path, project_dir: Path) -> list:
}
except json.JSONDecodeError as e:
# Attempt to auto-fix the plan and retry
if auto_fix_plan(spec_dir):
# Retry after fix
try:
with open(plan_file) as f:
plan = json.load(f)
subtask_found = _update_subtask_in_plan(
plan, subtask_id, status, notes
)
if subtask_found:
write_json_atomic(plan_file, plan, indent=2)
return {
"content": [
{
"type": "text",
"text": f"Successfully updated subtask '{subtask_id}' to status '{status}' (after auto-fix)",
}
]
}
else:
return {
"content": [
{
"type": "text",
"text": f"Error: Subtask '{subtask_id}' not found in implementation plan (after auto-fix)",
}
]
}
except Exception as retry_err:
logging.warning(
f"Subtask update retry failed after auto-fix: {retry_err}"
)
return {
"content": [
{
"type": "text",
"text": f"Error: Subtask update failed after auto-fix: {retry_err}",
}
]
}
return {
"content": [
{
+117 -7
View File
@@ -6,11 +6,98 @@ Automated fixes for common implementation plan issues.
"""
import json
import logging
import re
from pathlib import Path
from core.file_utils import write_json_atomic
from core.plan_normalization import normalize_subtask_aliases
def _repair_json_syntax(content: str) -> str | None:
"""
Attempt to repair common JSON syntax errors.
Args:
content: Raw JSON string that failed to parse
Returns:
Repaired JSON string if successful, None if repair failed
"""
if not content or not content.strip():
return None
# Defensive limit on input size to prevent processing extremely large malformed files.
# Implementation plans are typically <100KB; 1MB provides ample headroom.
max_content_size = 1024 * 1024 # 1 MB
if len(content) > max_content_size:
logging.warning(
f"JSON repair skipped: content size {len(content)} exceeds limit {max_content_size}"
)
return None
repaired = content
# Remove trailing commas before closing brackets/braces
# Match: comma followed by optional whitespace and closing bracket/brace
repaired = re.sub(r",(\s*[}\]])", r"\1", repaired)
# Strip string contents before counting brackets to avoid counting
# brackets inside JSON string values (e.g., {"desc": "array[0]"})
stripped = re.sub(r'"(?:[^"\\]|\\.)*"', '""', repaired)
# Handle truncated JSON by attempting to close open brackets/braces
# Use stack-based approach to track bracket order for correct closing
bracket_stack: list[str] = []
for char in stripped:
if char == "{":
bracket_stack.append("{")
elif char == "[":
bracket_stack.append("[")
elif char == "}":
if bracket_stack and bracket_stack[-1] == "{":
bracket_stack.pop()
elif char == "]":
if bracket_stack and bracket_stack[-1] == "[":
bracket_stack.pop()
if bracket_stack:
# Try to find a reasonable truncation point and close
# First, strip any incomplete key-value pair at the end
# Pattern: trailing incomplete string or number after last complete element
repaired = re.sub(r',\s*"(?:[^"\\]|\\.)*$', "", repaired) # Incomplete key
repaired = re.sub(r",\s*$", "", repaired) # Trailing comma
repaired = re.sub(
r':\s*"(?:[^"\\]|\\.)*$', ': ""', repaired
) # Incomplete string value
repaired = re.sub(r":\s*[0-9.]+$", ": 0", repaired) # Incomplete number
# Close remaining open brackets in reverse order (stack-based)
repaired = repaired.rstrip()
for bracket in reversed(bracket_stack):
if bracket == "{":
repaired += "}"
elif bracket == "[":
repaired += "]"
# Fix unquoted string values (common LLM error)
# Match: quoted key followed by colon and unquoted word
# Require a quoted key to avoid matching inside string values
# (e.g., {"description": "status: pending review"} should not be modified)
repaired = re.sub(
r'("[^"]+"\s*):\s*(pending|in_progress|completed|failed|done|backlog)\s*([,}\]])',
r'\1: "\2"\3',
repaired,
)
# Try to parse the repaired JSON
try:
json.loads(repaired)
return repaired
except json.JSONDecodeError:
return None
def _normalize_status(value: object) -> str:
"""Normalize common status variants to schema-compliant values."""
if not isinstance(value, str):
@@ -35,6 +122,9 @@ def _normalize_status(value: object) -> str:
def auto_fix_plan(spec_dir: Path) -> bool:
"""Attempt to auto-fix common implementation_plan.json issues.
This function handles both structural issues (missing fields, wrong types)
and syntax issues (trailing commas, truncated JSON).
Args:
spec_dir: Path to the spec directory
@@ -46,10 +136,29 @@ def auto_fix_plan(spec_dir: Path) -> bool:
if not plan_file.exists():
return False
plan = None
json_repaired = False
try:
with open(plan_file, encoding="utf-8") as f:
plan = json.load(f)
except (OSError, json.JSONDecodeError):
content = f.read()
plan = json.loads(content)
except json.JSONDecodeError:
# Attempt JSON syntax repair
try:
with open(plan_file, encoding="utf-8") as f:
content = f.read()
repaired = _repair_json_syntax(content)
if repaired:
plan = json.loads(repaired)
json_repaired = True
logging.info(f"JSON syntax repaired: {plan_file}")
except Exception as e:
logging.warning(f"JSON repair attempt failed for {plan_file}: {e}")
except OSError:
return False
if plan is None:
return False
fixed = False
@@ -169,12 +278,13 @@ def auto_fix_plan(spec_dir: Path) -> bool:
subtask["status"] = normalized_status
fixed = True
if fixed:
if fixed or json_repaired:
try:
with open(plan_file, "w", encoding="utf-8") as f:
json.dump(plan, f, indent=2, ensure_ascii=False)
# Use atomic write to prevent file corruption if interrupted
write_json_atomic(plan_file, plan, indent=2, ensure_ascii=False)
except OSError:
return False
print(f"Auto-fixed: {plan_file}")
if fixed:
logging.info(f"Auto-fixed: {plan_file}")
return fixed
return fixed or json_repaired
+20 -37
View File
@@ -2,8 +2,8 @@ import { app } from 'electron';
import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, Dirent } from 'fs';
import path from 'path';
import { v4 as uuidv4 } from 'uuid';
import type { Project, ProjectSettings, Task, TaskStatus, TaskMetadata, TaskErrorInfo, ImplementationPlan, ReviewReason, PlanSubtask } from '../shared/types';
import { DEFAULT_PROJECT_SETTINGS, AUTO_BUILD_PATHS, getSpecsDir } from '../shared/constants';
import type { Project, ProjectSettings, Task, TaskStatus, TaskMetadata, ImplementationPlan, ReviewReason, PlanSubtask } from '../shared/types';
import { DEFAULT_PROJECT_SETTINGS, AUTO_BUILD_PATHS, getSpecsDir, JSON_ERROR_PREFIX, JSON_ERROR_TITLE_SUFFIX } from '../shared/constants';
import { getAutoBuildPath, isInitialized } from './project-initializer';
import { getTaskWorktreeDir } from './worktree-paths';
@@ -384,7 +384,8 @@ export class ProjectStore {
// Try to read implementation plan
let plan: ImplementationPlan | null = null;
let parseError: TaskErrorInfo | null = null;
let hasJsonError = false;
let jsonErrorMessage = '';
if (existsSync(planPath)) {
console.warn(`[ProjectStore] Loading implementation_plan.json for spec: ${dir.name} from ${location}`);
try {
@@ -398,15 +399,10 @@ export class ProjectStore {
subtaskCount: plan?.phases?.flatMap(p => p.subtasks || []).length || 0
});
} catch (err) {
const errorMessage = err instanceof Error ? err.message : String(err);
parseError = {
key: 'errors:task.parseImplementationPlan',
meta: {
specId: dir.name,
error: errorMessage.slice(0, 500)
}
};
console.error(`[ProjectStore] Failed to parse implementation_plan.json for ${dir.name}:`, errorMessage);
// Don't skip - create task with error indicator so user knows it exists
hasJsonError = true;
jsonErrorMessage = err instanceof Error ? err.message : String(err);
console.error(`[ProjectStore] JSON parse error for spec ${dir.name}:`, jsonErrorMessage);
}
} else {
console.warn(`[ProjectStore] No implementation_plan.json found for spec: ${dir.name} at ${planPath}`);
@@ -464,21 +460,14 @@ export class ProjectStore {
}
// Determine task status and review reason from plan
// If there's a parse error, override to error status
let finalStatus: TaskStatus;
let finalDescription = description;
let finalReviewReason: ReviewReason | undefined = undefined;
let finalErrorInfo: TaskErrorInfo | undefined = undefined;
if (parseError) {
finalStatus = 'error';
finalErrorInfo = parseError;
console.error(`[ProjectStore] Creating error task for ${dir.name}:`, parseError);
} else {
const { status, reviewReason } = this.determineTaskStatusAndReason(plan, specPath, metadata);
finalStatus = status;
finalReviewReason = reviewReason;
}
// For JSON errors, store just the raw error - renderer will use i18n to format
const finalDescription = hasJsonError
? `${JSON_ERROR_PREFIX}${jsonErrorMessage}`
: description;
// Tasks with JSON errors go to human_review with errors reason
const { status: finalStatus, reviewReason: finalReviewReason } = hasJsonError
? { status: 'human_review' as TaskStatus, reviewReason: 'errors' as ReviewReason }
: this.determineTaskStatusAndReason(plan, specPath, metadata);
// Extract subtasks from plan (handle both 'subtasks' and 'chunks' naming)
const subtasks = plan?.phases?.flatMap((phase) => {
@@ -498,8 +487,9 @@ export class ProjectStore {
const stagedAt = planWithStaged?.stagedAt;
// Determine title - check if feature looks like a spec ID (e.g., "054-something-something")
let title = plan?.feature || plan?.title || dir.name;
const looksLikeSpecId = /^\d{3}-/.test(title);
// For JSON error tasks, use directory name with marker for i18n suffix
let title = hasJsonError ? `${dir.name}${JSON_ERROR_TITLE_SUFFIX}` : (plan?.feature || plan?.title || dir.name);
const looksLikeSpecId = /^\d{3}-/.test(title) && !hasJsonError;
if (looksLikeSpecId && existsSync(specFilePath)) {
try {
const specContent = readFileSync(specFilePath, 'utf-8');
@@ -527,7 +517,6 @@ export class ProjectStore {
logs: [],
metadata,
...(finalReviewReason !== undefined && { reviewReason: finalReviewReason }),
...(finalErrorInfo !== undefined && { errorInfo: finalErrorInfo }),
stagedInMainProject,
stagedAt,
location, // Add location metadata (main vs worktree)
@@ -607,8 +596,7 @@ export class ProjectStore {
'human_review': 'human_review',
'ai_review': 'ai_review',
'pr_created': 'pr_created', // PR has been created for this task
'backlog': 'backlog',
'error': 'error' // Preserves error status from JSON parse failures
'backlog': 'backlog'
};
const storedStatus = statusMap[plan.status];
@@ -622,11 +610,6 @@ export class ProjectStore {
return { status: 'pr_created' };
}
// If task has an error status, always respect that (from JSON parse failures)
if (storedStatus === 'error') {
return { status: 'error' };
}
// For other stored statuses, validate against calculated status
if (storedStatus) {
// Planning/coding status from the backend should be respected even if subtasks aren't in progress yet
@@ -19,7 +19,7 @@ import {
sortableKeyboardCoordinates,
verticalListSortingStrategy
} from '@dnd-kit/sortable';
import { Plus, Inbox, Loader2, Eye, CheckCircle2, Archive, RefreshCw, AlertCircle } from 'lucide-react';
import { Plus, Inbox, Loader2, Eye, CheckCircle2, Archive, RefreshCw } from 'lucide-react';
import { ScrollArea } from './ui/scroll-area';
import { Button } from './ui/button';
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
@@ -145,12 +145,6 @@ const getEmptyStateContent = (status: TaskStatus, t: (key: string) => string): {
message: t('kanban.emptyDone'),
subtext: t('kanban.emptyDoneHint')
};
case 'error':
return {
icon: <AlertCircle className="h-6 w-6 text-destructive/50" />,
message: t('kanban.emptyError'),
subtext: t('kanban.emptyErrorHint')
};
default:
return {
icon: <Inbox className="h-6 w-6 text-muted-foreground/50" />,
@@ -211,8 +205,6 @@ const DroppableColumn = memo(function DroppableColumn({ status, tasks, onTaskCli
return 'column-human-review';
case 'done':
return 'column-done';
case 'error':
return 'column-error';
default:
return 'border-t-muted-foreground/30';
}
@@ -392,7 +384,6 @@ export function KanbanBoard({ tasks, onTaskClick, onNewTaskClick, onRefresh, isR
const tasksByStatus = useMemo(() => {
// Note: pr_created tasks are shown in the 'done' column since they're essentially complete
const grouped: Record<typeof TASK_STATUS_COLUMNS[number], Task[]> = {
error: [],
backlog: [],
in_progress: [],
ai_review: [],
@@ -26,7 +26,9 @@ import {
EXECUTION_PHASE_LABELS,
EXECUTION_PHASE_BADGE_COLORS,
TASK_STATUS_COLUMNS,
TASK_STATUS_LABELS
TASK_STATUS_LABELS,
JSON_ERROR_PREFIX,
JSON_ERROR_TITLE_SUFFIX
} from '../../shared/constants';
import { startTask, stopTask, checkTaskRunning, recoverStuckTask, isIncompleteHumanReview, archiveTasks } from '../stores/task-store';
import type { Task, TaskCategory, ReviewReason, TaskStatus } from '../../shared/types';
@@ -96,7 +98,7 @@ function taskCardPropsAreEqual(prevProps: TaskCardProps, nextProps: TaskCardProp
}
export const TaskCard = memo(function TaskCard({ task, onClick, onStatusChange }: TaskCardProps) {
const { t } = useTranslation('tasks');
const { t } = useTranslation(['tasks', 'errors']);
const [isStuck, setIsStuck] = useState(false);
const [isRecovering, setIsRecovering] = useState(false);
const stuckCheckRef = useRef<{ timeout: NodeJS.Timeout | null; interval: NodeJS.Timeout | null }>({
@@ -113,10 +115,26 @@ export const TaskCard = memo(function TaskCard({ task, onClick, onStatusChange }
// Memoize expensive computations to avoid running on every render
// Truncate description for card display - full description shown in modal
const sanitizedDescription = useMemo(
() => task.description ? sanitizeMarkdownForDisplay(task.description, 120) : null,
[task.description]
);
// Handle JSON error tasks with i18n
const sanitizedDescription = useMemo(() => {
if (!task.description) return null;
// Check for JSON error marker and use i18n
if (task.description.startsWith(JSON_ERROR_PREFIX)) {
const errorMessage = task.description.slice(JSON_ERROR_PREFIX.length);
const translatedDesc = t('errors:task.jsonError.description', { error: errorMessage });
return sanitizeMarkdownForDisplay(translatedDesc, 120);
}
return sanitizeMarkdownForDisplay(task.description, 120);
}, [task.description, t]);
// Memoize title with JSON error suffix handling
const displayTitle = useMemo(() => {
if (task.title.endsWith(JSON_ERROR_TITLE_SUFFIX)) {
const baseName = task.title.slice(0, -JSON_ERROR_TITLE_SUFFIX.length);
return `${baseName} ${t('errors:task.jsonError.titleSuffix')}`;
}
return task.title;
}, [task.title, t]);
// Memoize relative time (recalculates only when updatedAt changes)
const relativeTime = useMemo(
@@ -267,8 +285,6 @@ export const TaskCard = memo(function TaskCard({ task, onClick, onStatusChange }
return 'success';
case 'done':
return 'success';
case 'error':
return 'destructive';
default:
return 'secondary';
}
@@ -286,8 +302,6 @@ export const TaskCard = memo(function TaskCard({ task, onClick, onStatusChange }
return t('columns.pr_created');
case 'done':
return t('status.complete');
case 'error':
return t('columns.error');
default:
return t('labels.pending');
}
@@ -327,9 +341,9 @@ export const TaskCard = memo(function TaskCard({ task, onClick, onStatusChange }
{/* Title - full width, no wrapper */}
<h3
className="font-semibold text-sm text-foreground line-clamp-2 leading-snug"
title={task.title}
title={displayTitle}
>
{task.title}
{displayTitle}
</h3>
{/* Description - sanitized to handle markdown content (memoized) */}
@@ -1,10 +1,11 @@
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { X, Pencil, AlertTriangle } from 'lucide-react';
import { Button } from '../ui/button';
import { Badge } from '../ui/badge';
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
import { cn } from '../../lib/utils';
import { TASK_STATUS_LABELS } from '../../../shared/constants';
import { TASK_STATUS_LABELS, JSON_ERROR_TITLE_SUFFIX } from '../../../shared/constants';
import type { Task } from '../../../shared/types';
interface TaskHeaderProps {
@@ -26,7 +27,16 @@ export function TaskHeader({
onClose,
onEdit
}: TaskHeaderProps) {
const { t } = useTranslation('tasks');
const { t } = useTranslation(['tasks', 'errors']);
// Handle JSON error suffix with i18n
const displayTitle = useMemo(() => {
if (task.title.endsWith(JSON_ERROR_TITLE_SUFFIX)) {
const baseName = task.title.slice(0, -JSON_ERROR_TITLE_SUFFIX.length);
return `${baseName} ${t('errors:task.jsonError.titleSuffix')}`;
}
return task.title;
}, [task.title, t]);
return (
<div className="flex items-start justify-between p-4 pb-3">
@@ -34,12 +44,12 @@ export function TaskHeader({
<Tooltip>
<TooltipTrigger asChild>
<h2 className="font-semibold text-lg text-foreground line-clamp-2 leading-snug cursor-default">
{task.title}
{displayTitle}
</h2>
</TooltipTrigger>
{task.title.length > 40 && (
{displayTitle.length > 40 && (
<TooltipContent side="bottom" className="max-w-xs">
<p className="text-sm">{task.title}</p>
<p className="text-sm">{displayTitle}</p>
</TooltipContent>
)}
</Tooltip>
@@ -29,7 +29,8 @@ import {
TASK_IMPACT_COLORS,
TASK_PRIORITY_LABELS,
TASK_PRIORITY_COLORS,
IDEATION_TYPE_LABELS
IDEATION_TYPE_LABELS,
JSON_ERROR_PREFIX
} from '../../../shared/constants';
import type { Task, TaskCategory } from '../../../shared/types';
@@ -51,7 +52,18 @@ interface TaskMetadataProps {
}
export function TaskMetadata({ task }: TaskMetadataProps) {
const { t } = useTranslation(['tasks']);
const { t } = useTranslation(['tasks', 'errors']);
// Handle JSON error description with i18n
const displayDescription = (() => {
if (!task.description) return null;
if (task.description.startsWith(JSON_ERROR_PREFIX)) {
const errorMessage = task.description.slice(JSON_ERROR_PREFIX.length);
return t('errors:task.jsonError.description', { error: errorMessage });
}
return task.description;
})();
const hasClassification = task.metadata && (
task.metadata.category ||
task.metadata.priority ||
@@ -141,14 +153,14 @@ export function TaskMetadata({ task }: TaskMetadataProps) {
</div>
{/* Description - Primary Content */}
{task.description && (
{displayDescription && (
<div className="bg-muted/30 rounded-lg px-4 py-3 border border-border/50 overflow-hidden max-w-full">
<div
className="prose prose-sm prose-invert max-w-none overflow-hidden prose-p:text-foreground/90 prose-p:leading-relaxed prose-headings:text-foreground prose-strong:text-foreground prose-li:text-foreground/90 prose-ul:my-2 prose-li:my-0.5 prose-a:break-all prose-pre:overflow-x-auto prose-img:max-w-full [&_img]:!max-w-full [&_img]:h-auto [&_code]:break-all [&_code]:whitespace-pre-wrap [&_*]:max-w-full"
style={{ wordBreak: 'break-word', overflowWrap: 'anywhere' }}
>
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{task.description}
{displayDescription}
</ReactMarkdown>
</div>
</div>
@@ -231,21 +231,11 @@ export const useTaskStore = create<TaskState>((set, get) => ({
// 1. Subtasks array is properly populated (not empty)
// 2. All subtasks are actually completed (for 'done' and 'ai_review' statuses)
const hasSubtasks = subtasks.length > 0;
const terminalStatuses: TaskStatus[] = ['human_review', 'pr_created', 'done', 'error'];
const terminalStatuses: TaskStatus[] = ['human_review', 'pr_created', 'done'];
// If task is currently in a terminal status, validate subtasks before allowing downgrade
// This prevents flip-flop when plan file is written with incomplete data
const shouldBlockTerminalTransition = (newStatus: TaskStatus): boolean => {
// Allow recovery from error status to backlog or in_progress
if (t.status === 'error' && (newStatus === 'backlog' || newStatus === 'in_progress')) {
return false; // Allow error recovery transitions
}
// Error status doesn't require completion validation (it's a failure state)
if (newStatus === 'error') {
return false; // Allow transitions to error without completion check
}
// Block if: moving to terminal status but subtasks indicate incomplete work
if (terminalStatuses.includes(newStatus) || newStatus === 'ai_review') {
// For ai_review, all subtasks must be completed
@@ -830,6 +820,9 @@ export function getTaskByGitHubIssue(issueNumber: number): Task | undefined {
export function isIncompleteHumanReview(task: Task): boolean {
if (task.status !== 'human_review') return false;
// JSON error tasks are intentionally in human_review with no subtasks - not incomplete
if (task.reviewReason === 'errors') return false;
// If no subtasks defined, task hasn't been planned yet (shouldn't be in human_review)
if (!task.subtasks || task.subtasks.length === 0) return true;
@@ -1156,10 +1156,6 @@ body {
border-top-color: var(--success);
}
.column-error {
border-top-color: var(--destructive);
}
/* Progress bar working animation - subtle glow sweep */
@keyframes progress-glow-sweep {
0% {
+18 -3
View File
@@ -9,7 +9,6 @@
// Task status columns in Kanban board order
export const TASK_STATUS_COLUMNS = [
'error',
'backlog',
'in_progress',
'ai_review',
@@ -22,7 +21,6 @@ export type TaskStatusColumn = typeof TASK_STATUS_COLUMNS[number];
// Status label translation keys (use with t() from react-i18next)
// Note: pr_created maps to 'done' column in Kanban view (see KanbanBoard.tsx)
export const TASK_STATUS_LABELS: Record<TaskStatusColumn | 'pr_created', string> = {
error: 'columns.error',
backlog: 'columns.backlog',
in_progress: 'columns.in_progress',
ai_review: 'columns.ai_review',
@@ -34,7 +32,6 @@ export const TASK_STATUS_LABELS: Record<TaskStatusColumn | 'pr_created', string>
// Status colors for UI
// Note: pr_created maps to 'done' column in Kanban view (see KanbanBoard.tsx)
export const TASK_STATUS_COLORS: Record<TaskStatusColumn | 'pr_created', string> = {
error: 'bg-destructive/10 text-destructive',
backlog: 'bg-muted text-muted-foreground',
in_progress: 'bg-info/10 text-info',
ai_review: 'bg-warning/10 text-warning',
@@ -217,3 +214,21 @@ export const ALLOWED_IMAGE_TYPES_DISPLAY = 'PNG, JPEG, GIF, WebP, SVG';
// Attachments directory name within spec folder
export const ATTACHMENTS_DIR = 'attachments';
// ============================================
// JSON Error Markers
// ============================================
/**
* Marker prefix for task descriptions that failed JSON parsing.
* Format: __JSON_ERROR__:<error message>
* Used in project-store.ts when loading tasks with malformed implementation_plan.json
*/
export const JSON_ERROR_PREFIX = '__JSON_ERROR__:';
/**
* Marker suffix for task titles that have JSON parsing errors.
* Appended to spec directory name, replaced with i18n suffix at render time.
* Used in project-store.ts when loading tasks with malformed implementation_plan.json
*/
export const JSON_ERROR_TITLE_SUFFIX = '__JSON_ERROR_SUFFIX__';
@@ -1,5 +1,9 @@
{
"task": {
"parseImplementationPlan": "Failed to parse implementation_plan.json for {{specId}}: {{error}}"
"parseImplementationPlan": "Failed to parse implementation_plan.json for {{specId}}: {{error}}",
"jsonError": {
"titleSuffix": "(JSON Error)",
"description": "⚠️ JSON Parse Error: {{error}}\n\nThe implementation_plan.json file is malformed. Run the backend auto-fix or manually repair the file."
}
}
}
@@ -53,7 +53,6 @@
"description": "Create your first task to get started"
},
"columns": {
"error": "Error",
"backlog": "Planning",
"in_progress": "In Progress",
"ai_review": "AI Review",
@@ -72,8 +71,6 @@
"emptyHumanReviewHint": "Tasks await your approval here",
"emptyDone": "No completed tasks",
"emptyDoneHint": "Approved tasks appear here",
"emptyError": "No error tasks",
"emptyErrorHint": "Error tasks will appear here when parsing fails",
"emptyDefault": "No tasks",
"dropHere": "Drop here",
"showArchived": "Show archived",
@@ -1,5 +1,9 @@
{
"task": {
"parseImplementationPlan": "Échec de l'analyse du fichier implementation_plan.json pour {{specId}} : {{error}}"
"parseImplementationPlan": "Échec de l'analyse du fichier implementation_plan.json pour {{specId}} : {{error}}",
"jsonError": {
"titleSuffix": "(Erreur JSON)",
"description": "⚠️ Erreur d'analyse JSON : {{error}}\n\nLe fichier implementation_plan.json est malformé. Exécutez la correction automatique du backend ou réparez le fichier manuellement."
}
}
}
@@ -53,7 +53,6 @@
"description": "Créez votre première tâche pour commencer"
},
"columns": {
"error": "Erreur",
"backlog": "Planification",
"in_progress": "En cours",
"ai_review": "Révision IA",
@@ -72,8 +71,6 @@
"emptyHumanReviewHint": "Les tâches attendent votre approbation ici",
"emptyDone": "Aucune tâche terminée",
"emptyDoneHint": "Les tâches approuvées apparaissent ici",
"emptyError": "Aucune tâche en erreur",
"emptyErrorHint": "Les tâches en erreur apparaîtront ici en cas d'échec d'analyse",
"emptyDefault": "Aucune tâche",
"dropHere": "Déposer ici",
"showArchived": "Afficher les archivées",
+1 -8
View File
@@ -5,7 +5,7 @@
import type { ThinkingLevel, PhaseModelConfig, PhaseThinkingConfig } from './settings';
import type { ExecutionPhase as ExecutionPhaseType, CompletablePhase } from '../constants/phase-protocol';
export type TaskStatus = 'backlog' | 'in_progress' | 'ai_review' | 'human_review' | 'pr_created' | 'done' | 'error';
export type TaskStatus = 'backlog' | 'in_progress' | 'ai_review' | 'human_review' | 'pr_created' | 'done';
// Reason why a task is in human_review status
// - 'completed': All subtasks done and QA passed, ready for final approval/merge
@@ -240,12 +240,6 @@ export interface TaskMetadata {
archivedInVersion?: string; // Version in which task was archived (from changelog)
}
// Structured error information for tasks with parse errors
export interface TaskErrorInfo {
key: string; // Translation key (e.g., 'errors:task.parseImplementationPlan')
meta?: { specId?: string; error?: string }; // Error context for substitution in translation
}
export interface Task {
id: string;
specId: string;
@@ -258,7 +252,6 @@ export interface Task {
qaReport?: QAReport;
logs: string[];
metadata?: TaskMetadata; // Rich metadata from ideation or manual entry
errorInfo?: TaskErrorInfo; // Structured error information for i18n (set when status is 'error')
executionProgress?: ExecutionProgress; // Real-time execution progress
releasedInVersion?: string; // Version in which this task was released
stagedInMainProject?: boolean; // True if changes were staged to main project (worktree merged with --no-commit)