aiwcli 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1248 -0
- package/bin/dev.cmd +3 -0
- package/bin/dev.js +16 -0
- package/bin/run.cmd +3 -0
- package/bin/run.js +19 -0
- package/dist/commands/branch.d.ts +45 -0
- package/dist/commands/branch.js +488 -0
- package/dist/commands/clean.d.ts +34 -0
- package/dist/commands/clean.js +186 -0
- package/dist/commands/clear.d.ts +51 -0
- package/dist/commands/clear.js +835 -0
- package/dist/commands/init/index.d.ts +107 -0
- package/dist/commands/init/index.js +565 -0
- package/dist/commands/launch.d.ts +21 -0
- package/dist/commands/launch.js +108 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/lib/base-command.d.ts +114 -0
- package/dist/lib/base-command.js +153 -0
- package/dist/lib/bmad-installer.d.ts +38 -0
- package/dist/lib/bmad-installer.js +145 -0
- package/dist/lib/claude-settings-types.d.ts +102 -0
- package/dist/lib/claude-settings-types.js +5 -0
- package/dist/lib/config.d.ts +25 -0
- package/dist/lib/config.js +46 -0
- package/dist/lib/debug.d.ts +39 -0
- package/dist/lib/debug.js +74 -0
- package/dist/lib/env-compat.d.ts +26 -0
- package/dist/lib/env-compat.js +35 -0
- package/dist/lib/errors.d.ts +126 -0
- package/dist/lib/errors.js +145 -0
- package/dist/lib/generic-merge.d.ts +74 -0
- package/dist/lib/generic-merge.js +105 -0
- package/dist/lib/git/branch.d.ts +67 -0
- package/dist/lib/git/branch.js +155 -0
- package/dist/lib/git/index.d.ts +11 -0
- package/dist/lib/git/index.js +13 -0
- package/dist/lib/git/safety-checks.d.ts +44 -0
- package/dist/lib/git/safety-checks.js +102 -0
- package/dist/lib/git/types.d.ts +31 -0
- package/dist/lib/git/types.js +6 -0
- package/dist/lib/git/worktree.d.ts +67 -0
- package/dist/lib/git/worktree.js +220 -0
- package/dist/lib/gitignore-manager.d.ts +10 -0
- package/dist/lib/gitignore-manager.js +60 -0
- package/dist/lib/hooks-merger.d.ts +28 -0
- package/dist/lib/hooks-merger.js +94 -0
- package/dist/lib/ide-path-resolver.d.ts +102 -0
- package/dist/lib/ide-path-resolver.js +129 -0
- package/dist/lib/index.d.ts +13 -0
- package/dist/lib/index.js +22 -0
- package/dist/lib/output.d.ts +51 -0
- package/dist/lib/output.js +76 -0
- package/dist/lib/paths.d.ts +66 -0
- package/dist/lib/paths.js +136 -0
- package/dist/lib/quiet.d.ts +12 -0
- package/dist/lib/quiet.js +17 -0
- package/dist/lib/settings-hierarchy.d.ts +42 -0
- package/dist/lib/settings-hierarchy.js +105 -0
- package/dist/lib/spawn.d.ts +105 -0
- package/dist/lib/spawn.js +157 -0
- package/dist/lib/spinner.d.ts +19 -0
- package/dist/lib/spinner.js +34 -0
- package/dist/lib/stdin.d.ts +48 -0
- package/dist/lib/stdin.js +60 -0
- package/dist/lib/template-installer.d.ts +92 -0
- package/dist/lib/template-installer.js +375 -0
- package/dist/lib/template-linter.d.ts +49 -0
- package/dist/lib/template-linter.js +173 -0
- package/dist/lib/template-merger.d.ts +47 -0
- package/dist/lib/template-merger.js +173 -0
- package/dist/lib/template-resolver.d.ts +20 -0
- package/dist/lib/template-resolver.js +60 -0
- package/dist/lib/terminal.d.ts +102 -0
- package/dist/lib/terminal.js +245 -0
- package/dist/lib/tty-detection.d.ts +62 -0
- package/dist/lib/tty-detection.js +83 -0
- package/dist/lib/user-utils.d.ts +5 -0
- package/dist/lib/user-utils.js +23 -0
- package/dist/lib/version.d.ts +99 -0
- package/dist/lib/version.js +144 -0
- package/dist/lib/watch-templates.d.ts +6 -0
- package/dist/lib/watch-templates.js +73 -0
- package/dist/lib/windsurf-hooks-hierarchy.d.ts +30 -0
- package/dist/lib/windsurf-hooks-hierarchy.js +66 -0
- package/dist/lib/windsurf-hooks-merger.d.ts +26 -0
- package/dist/lib/windsurf-hooks-merger.js +53 -0
- package/dist/lib/windsurf-hooks-types.d.ts +33 -0
- package/dist/lib/windsurf-hooks-types.js +5 -0
- package/dist/templates/CLAUDE.md +174 -0
- package/dist/templates/_shared/.claude/commands/handoff.md +14 -0
- package/dist/templates/_shared/.claude/settings.json +61 -0
- package/dist/templates/_shared/.codex/workflows/handoff.md +14 -0
- package/dist/templates/_shared/.windsurf/workflows/handoff.md +14 -0
- package/dist/templates/_shared/hooks/__init__.py +16 -0
- package/dist/templates/_shared/hooks/archive_plan.py +270 -0
- package/dist/templates/_shared/hooks/context_enforcer.py +621 -0
- package/dist/templates/_shared/hooks/context_monitor.py +322 -0
- package/dist/templates/_shared/hooks/file-suggestion.py +188 -0
- package/dist/templates/_shared/hooks/task_create_capture.py +194 -0
- package/dist/templates/_shared/hooks/task_update_capture.py +254 -0
- package/dist/templates/_shared/hooks/user_prompt_submit.py +157 -0
- package/dist/templates/_shared/lib/__init__.py +1 -0
- package/dist/templates/_shared/lib/base/__init__.py +49 -0
- package/dist/templates/_shared/lib/base/__pycache__/constants.cpython-313.pyc +0 -0
- package/dist/templates/_shared/lib/base/atomic_write.py +180 -0
- package/dist/templates/_shared/lib/base/constants.py +299 -0
- package/dist/templates/_shared/lib/base/inference.py +189 -0
- package/dist/templates/_shared/lib/base/utils.py +216 -0
- package/dist/templates/_shared/lib/context/__init__.py +119 -0
- package/dist/templates/_shared/lib/context/__pycache__/__init__.cpython-313.pyc +0 -0
- package/dist/templates/_shared/lib/context/__pycache__/cache.cpython-313.pyc +0 -0
- package/dist/templates/_shared/lib/context/__pycache__/context_manager.cpython-313.pyc +0 -0
- package/dist/templates/_shared/lib/context/__pycache__/event_log.cpython-313.pyc +0 -0
- package/dist/templates/_shared/lib/context/cache.py +446 -0
- package/dist/templates/_shared/lib/context/context_manager.py +1171 -0
- package/dist/templates/_shared/lib/context/discovery.py +486 -0
- package/dist/templates/_shared/lib/context/event_log.py +308 -0
- package/dist/templates/_shared/lib/context/plan_archive.py +247 -0
- package/dist/templates/_shared/lib/context/task_sync.py +367 -0
- package/dist/templates/_shared/lib/handoff/__init__.py +22 -0
- package/dist/templates/_shared/lib/handoff/document_generator.py +307 -0
- package/dist/templates/_shared/lib/templates/README.md +215 -0
- package/dist/templates/_shared/lib/templates/__init__.py +40 -0
- package/dist/templates/_shared/lib/templates/formatters.py +147 -0
- package/dist/templates/_shared/lib/templates/plan_context.py +119 -0
- package/dist/templates/_shared/scripts/save_handoff.py +99 -0
- package/dist/templates/_shared/workflows/handoff.md +212 -0
- package/dist/templates/cc-native/.claude/agents/cc-native/ACCESSIBILITY-TESTER.md +80 -0
- package/dist/templates/cc-native/.claude/agents/cc-native/ARCHITECT-REVIEWER.md +75 -0
- package/dist/templates/cc-native/.claude/agents/cc-native/ASSUMPTION-CHAIN-TRACER.md +239 -0
- package/dist/templates/cc-native/.claude/agents/cc-native/CLARITY-AUDITOR.md +109 -0
- package/dist/templates/cc-native/.claude/agents/cc-native/CODE-REVIEWER.md +71 -0
- package/dist/templates/cc-native/.claude/agents/cc-native/COMPLETENESS-CHECKER.md +104 -0
- package/dist/templates/cc-native/.claude/agents/cc-native/CONTEXT-EXTRACTOR.md +93 -0
- package/dist/templates/cc-native/.claude/agents/cc-native/DEVILS-ADVOCATE.md +223 -0
- package/dist/templates/cc-native/.claude/agents/cc-native/DOCUMENTATION-REVIEWER.md +73 -0
- package/dist/templates/cc-native/.claude/agents/cc-native/FEASIBILITY-ANALYST.md +93 -0
- package/dist/templates/cc-native/.claude/agents/cc-native/FRESH-PERSPECTIVE.md +103 -0
- package/dist/templates/cc-native/.claude/agents/cc-native/HANDOFF-READINESS.md +145 -0
- package/dist/templates/cc-native/.claude/agents/cc-native/HIDDEN-COMPLEXITY-DETECTOR.md +248 -0
- package/dist/templates/cc-native/.claude/agents/cc-native/INCENTIVE-MAPPER.md +235 -0
- package/dist/templates/cc-native/.claude/agents/cc-native/PENETRATION-TESTER.md +80 -0
- package/dist/templates/cc-native/.claude/agents/cc-native/PERFORMANCE-ENGINEER.md +76 -0
- package/dist/templates/cc-native/.claude/agents/cc-native/PLAN-ORCHESTRATOR.md +141 -0
- package/dist/templates/cc-native/.claude/agents/cc-native/PRECEDENT-FINDER.md +240 -0
- package/dist/templates/cc-native/.claude/agents/cc-native/REVERSIBILITY-ANALYST.md +211 -0
- package/dist/templates/cc-native/.claude/agents/cc-native/RISK-ASSESSOR.md +101 -0
- package/dist/templates/cc-native/.claude/agents/cc-native/SECOND-ORDER-ANALYST.md +197 -0
- package/dist/templates/cc-native/.claude/agents/cc-native/SIMPLICITY-GUARDIAN.md +97 -0
- package/dist/templates/cc-native/.claude/agents/cc-native/SKEPTIC.md +349 -0
- package/dist/templates/cc-native/.claude/agents/cc-native/STAKEHOLDER-ADVOCATE.md +106 -0
- package/dist/templates/cc-native/.claude/agents/cc-native/TRADE-OFF-ILLUMINATOR.md +205 -0
- package/dist/templates/cc-native/.claude/commands/cc-native/fresh-perspective.md +8 -0
- package/dist/templates/cc-native/.claude/commands/cc-native/specdev.md +10 -0
- package/dist/templates/cc-native/.claude/settings.json +119 -0
- package/dist/templates/cc-native/.windsurf/workflows/cc-native/fix.md +8 -0
- package/dist/templates/cc-native/.windsurf/workflows/cc-native/fresh-perspective.md +8 -0
- package/dist/templates/cc-native/.windsurf/workflows/cc-native/implement.md +8 -0
- package/dist/templates/cc-native/.windsurf/workflows/cc-native/research.md +8 -0
- package/dist/templates/cc-native/CC-NATIVE-README.md +192 -0
- package/dist/templates/cc-native/MIGRATION.md +86 -0
- package/dist/templates/cc-native/TEMPLATE-SCHEMA.md +331 -0
- package/dist/templates/cc-native/_cc-native/docs/PERMISSION_REQUEST_VERIFICATION.md +147 -0
- package/dist/templates/cc-native/_cc-native/hooks/__pycache__/add_plan_context.cpython-313.pyc +0 -0
- package/dist/templates/cc-native/_cc-native/hooks/__pycache__/archive_plan.cpython-313.pyc +0 -0
- package/dist/templates/cc-native/_cc-native/hooks/__pycache__/cc-native-agent-review.cpython-313.pyc +0 -0
- package/dist/templates/cc-native/_cc-native/hooks/__pycache__/cc-native-plan-review.cpython-313.pyc +0 -0
- package/dist/templates/cc-native/_cc-native/hooks/__pycache__/test_permission_request.cpython-313.pyc +0 -0
- package/dist/templates/cc-native/_cc-native/hooks/add_plan_context.py +150 -0
- package/dist/templates/cc-native/_cc-native/hooks/cc-native-plan-review.py +746 -0
- package/dist/templates/cc-native/_cc-native/hooks/suggest-fresh-perspective.py +339 -0
- package/dist/templates/cc-native/_cc-native/lib/__init__.py +57 -0
- package/dist/templates/cc-native/_cc-native/lib/__pycache__/__init__.cpython-313.pyc +0 -0
- package/dist/templates/cc-native/_cc-native/lib/__pycache__/orchestrator.cpython-313.pyc +0 -0
- package/dist/templates/cc-native/_cc-native/lib/__pycache__/state.cpython-313.pyc +0 -0
- package/dist/templates/cc-native/_cc-native/lib/__pycache__/utils.cpython-313.pyc +0 -0
- package/dist/templates/cc-native/_cc-native/lib/async_archive.py +68 -0
- package/dist/templates/cc-native/_cc-native/lib/atomic_write.py +98 -0
- package/dist/templates/cc-native/_cc-native/lib/constants.py +45 -0
- package/dist/templates/cc-native/_cc-native/lib/orchestrator.py +273 -0
- package/dist/templates/cc-native/_cc-native/lib/reviewers/__init__.py +28 -0
- package/dist/templates/cc-native/_cc-native/lib/reviewers/__pycache__/__init__.cpython-313.pyc +0 -0
- package/dist/templates/cc-native/_cc-native/lib/reviewers/__pycache__/agent.cpython-313.pyc +0 -0
- package/dist/templates/cc-native/_cc-native/lib/reviewers/__pycache__/base.cpython-313.pyc +0 -0
- package/dist/templates/cc-native/_cc-native/lib/reviewers/__pycache__/codex.cpython-313.pyc +0 -0
- package/dist/templates/cc-native/_cc-native/lib/reviewers/__pycache__/gemini.cpython-313.pyc +0 -0
- package/dist/templates/cc-native/_cc-native/lib/reviewers/agent.py +164 -0
- package/dist/templates/cc-native/_cc-native/lib/reviewers/base.py +89 -0
- package/dist/templates/cc-native/_cc-native/lib/reviewers/codex.py +119 -0
- package/dist/templates/cc-native/_cc-native/lib/reviewers/gemini.py +103 -0
- package/dist/templates/cc-native/_cc-native/lib/state.py +251 -0
- package/dist/templates/cc-native/_cc-native/lib/utils.py +830 -0
- package/dist/templates/cc-native/_cc-native/plan-review.config.json +76 -0
- package/dist/templates/cc-native/_cc-native/scripts/__pycache__/aggregate_agents.cpython-313.pyc +0 -0
- package/dist/templates/cc-native/_cc-native/scripts/aggregate_agents.py +151 -0
- package/dist/templates/cc-native/_cc-native/workflows/fresh-perspective.md +134 -0
- package/dist/templates/cc-native/_cc-native/workflows/specdev.md +9 -0
- package/dist/types/exit-codes.d.ts +11 -0
- package/dist/types/exit-codes.js +10 -0
- package/dist/types/index.d.ts +5 -0
- package/dist/types/index.js +7 -0
- package/oclif.manifest.json +405 -0
- package/package.json +109 -0
|
@@ -0,0 +1,830 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CC-Native shared utilities.
|
|
3
|
+
|
|
4
|
+
Provides common functions used across all cc-native hooks:
|
|
5
|
+
- Core utilities (eprint, now_local, project_dir, sanitize_filename)
|
|
6
|
+
- Plan hash deduplication (compute_plan_hash, get_review_marker_path, etc.)
|
|
7
|
+
- JSON parsing (parse_json_maybe, coerce_to_review, worst_verdict)
|
|
8
|
+
- Artifact writing (format_markdown, write_artifacts, find_plan_file)
|
|
9
|
+
- Constants (REVIEW_SCHEMA, DEFAULT_DISPLAY)
|
|
10
|
+
- Dataclasses (ReviewerResult)
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import hashlib
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
import re
|
|
17
|
+
import sys
|
|
18
|
+
import tempfile
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
from datetime import datetime
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
23
|
+
|
|
24
|
+
try:
|
|
25
|
+
from .atomic_write import atomic_write
|
|
26
|
+
from .constants import ENABLE_ROBUST_PLAN_WRITES
|
|
27
|
+
except ImportError:
|
|
28
|
+
# When imported directly via sys.path (not as a package)
|
|
29
|
+
from atomic_write import atomic_write
|
|
30
|
+
from constants import ENABLE_ROBUST_PLAN_WRITES
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# ---------------------------
|
|
34
|
+
# Constants
|
|
35
|
+
# ---------------------------
|
|
36
|
+
|
|
37
|
+
DEFAULT_DISPLAY: Dict[str, int] = {
|
|
38
|
+
"maxIssues": 12,
|
|
39
|
+
"maxMissingSections": 12,
|
|
40
|
+
"maxQuestions": 12,
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
DEFAULT_SANITIZATION: Dict[str, int] = {
|
|
44
|
+
"maxSessionIdLength": 32,
|
|
45
|
+
"maxTitleLength": 50,
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
REVIEW_SCHEMA: Dict[str, Any] = {
|
|
49
|
+
"type": "object",
|
|
50
|
+
"properties": {
|
|
51
|
+
"verdict": {"type": "string", "enum": ["pass", "warn", "fail"]},
|
|
52
|
+
"summary": {"type": "string", "minLength": 20},
|
|
53
|
+
"issues": {
|
|
54
|
+
"type": "array",
|
|
55
|
+
"items": {
|
|
56
|
+
"type": "object",
|
|
57
|
+
"properties": {
|
|
58
|
+
"severity": {"type": "string", "enum": ["high", "medium", "low"]},
|
|
59
|
+
"category": {"type": "string"},
|
|
60
|
+
"issue": {"type": "string"},
|
|
61
|
+
"suggested_fix": {"type": "string"},
|
|
62
|
+
},
|
|
63
|
+
"required": ["severity", "category", "issue", "suggested_fix"],
|
|
64
|
+
"additionalProperties": False,
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
"missing_sections": {"type": "array", "items": {"type": "string"}},
|
|
68
|
+
"questions": {"type": "array", "items": {"type": "string"}},
|
|
69
|
+
},
|
|
70
|
+
"required": ["verdict", "summary", "issues", "missing_sections", "questions"],
|
|
71
|
+
"additionalProperties": False,
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# ---------------------------
|
|
76
|
+
# Dataclasses
|
|
77
|
+
# ---------------------------
|
|
78
|
+
|
|
79
|
+
@dataclass
|
|
80
|
+
class ReviewerResult:
|
|
81
|
+
"""Result from a plan reviewer (Codex, Gemini, or Claude agent)."""
|
|
82
|
+
name: str
|
|
83
|
+
ok: bool
|
|
84
|
+
verdict: str # pass|warn|fail|error|skip
|
|
85
|
+
data: Dict[str, Any]
|
|
86
|
+
raw: str
|
|
87
|
+
err: str
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
# ---------------------------
|
|
91
|
+
# Core utilities
|
|
92
|
+
# ---------------------------
|
|
93
|
+
|
|
94
|
+
def eprint(*args: Any) -> None:
|
|
95
|
+
"""Print to stderr."""
|
|
96
|
+
print(*args, file=sys.stderr)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def now_local() -> datetime:
|
|
100
|
+
"""Get current local datetime."""
|
|
101
|
+
return datetime.now()
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def project_dir(payload: Dict[str, Any]) -> Path:
|
|
105
|
+
"""Get project directory from payload or environment."""
|
|
106
|
+
p = os.environ.get("CLAUDE_PROJECT_DIR") or payload.get("cwd") or os.getcwd()
|
|
107
|
+
return Path(p)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def sanitize_filename(s: str, max_len: int = 32) -> str:
|
|
111
|
+
"""Sanitize string for use in filename."""
|
|
112
|
+
s = re.sub(r"[^A-Za-z0-9._-]+", "_", s)
|
|
113
|
+
return s.strip("._-")[:max_len] or "unknown"
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def sanitize_title(s: str, max_len: int = 50) -> str:
|
|
117
|
+
"""Sanitize title for use in filename (with space-to-dash conversion)."""
|
|
118
|
+
s = s.replace(' ', '-')
|
|
119
|
+
s = re.sub(r"[^A-Za-z0-9._-]+", "_", s)
|
|
120
|
+
s = re.sub(r"[-_]+", "-", s)
|
|
121
|
+
return s.strip("._-")[:max_len] or "unknown"
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def extract_plan_title(plan: str) -> Optional[str]:
|
|
125
|
+
"""Extract title from '# Plan: <title>' line in plan content."""
|
|
126
|
+
for line in plan.split('\n'):
|
|
127
|
+
line = line.strip()
|
|
128
|
+
if line.startswith('# Plan:'):
|
|
129
|
+
title = line[7:].strip()
|
|
130
|
+
return title if title else None
|
|
131
|
+
return None
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def extract_task_from_context(plan: str) -> Optional[str]:
|
|
135
|
+
"""Extract Task from Evaluation Context section as fallback title."""
|
|
136
|
+
# Look for **Task**: ... or **Task Summary**: ... patterns
|
|
137
|
+
patterns = [
|
|
138
|
+
r'\*\*Task\*\*:\s*(.+?)(?:\n|$)',
|
|
139
|
+
r'\*\*Task Summary\*\*:\s*(.+?)(?:\n|$)',
|
|
140
|
+
]
|
|
141
|
+
for pattern in patterns:
|
|
142
|
+
match = re.search(pattern, plan)
|
|
143
|
+
if match:
|
|
144
|
+
task = match.group(1).strip()
|
|
145
|
+
# Truncate to reasonable title length
|
|
146
|
+
if len(task) > 50:
|
|
147
|
+
task = task[:47] + "..."
|
|
148
|
+
return task
|
|
149
|
+
return None
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
# ---------------------------
|
|
153
|
+
# Plan hash deduplication
|
|
154
|
+
# ---------------------------
|
|
155
|
+
|
|
156
|
+
def compute_plan_hash(plan_content: str) -> str:
|
|
157
|
+
"""Compute a hash of the plan content."""
|
|
158
|
+
return hashlib.sha256(plan_content.encode("utf-8")).hexdigest()[:16]
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def get_review_marker_path(session_id: str) -> Path:
|
|
162
|
+
"""Get path to review marker file for this session."""
|
|
163
|
+
safe_id = re.sub(r'[^a-zA-Z0-9_-]', '_', session_id)[:32]
|
|
164
|
+
return Path(tempfile.gettempdir()) / f"cc-native-plan-reviewed-{safe_id}.json"
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def is_plan_already_reviewed(session_id: str, plan_hash: str) -> bool:
|
|
168
|
+
"""Check if this exact plan has already been reviewed in this session."""
|
|
169
|
+
marker_path = get_review_marker_path(session_id)
|
|
170
|
+
if not marker_path.exists():
|
|
171
|
+
return False
|
|
172
|
+
try:
|
|
173
|
+
data = json.loads(marker_path.read_text(encoding="utf-8"))
|
|
174
|
+
stored_hash = data.get("plan_hash", "")
|
|
175
|
+
return stored_hash == plan_hash
|
|
176
|
+
except Exception:
|
|
177
|
+
return False
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def mark_plan_reviewed(
|
|
181
|
+
session_id: str,
|
|
182
|
+
plan_hash: str,
|
|
183
|
+
hook_name: str = "cc-native",
|
|
184
|
+
iteration_state: Optional[Dict[str, Any]] = None,
|
|
185
|
+
) -> None:
|
|
186
|
+
"""Mark this plan as reviewed (stores hash in marker file).
|
|
187
|
+
|
|
188
|
+
Args:
|
|
189
|
+
session_id: The session identifier
|
|
190
|
+
plan_hash: Hash of the plan content
|
|
191
|
+
hook_name: Name of the hook (for logging)
|
|
192
|
+
iteration_state: Optional iteration state dict with current, max, verdict info
|
|
193
|
+
"""
|
|
194
|
+
marker = get_review_marker_path(session_id)
|
|
195
|
+
try:
|
|
196
|
+
data: Dict[str, Any] = {
|
|
197
|
+
"plan_hash": plan_hash,
|
|
198
|
+
"reviewed_at": datetime.now().isoformat(),
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
# Include iteration info if provided
|
|
202
|
+
if iteration_state:
|
|
203
|
+
data["iteration"] = {
|
|
204
|
+
"current": iteration_state.get("current", 1),
|
|
205
|
+
"max": iteration_state.get("max", 1),
|
|
206
|
+
"complexity": iteration_state.get("complexity", "unknown"),
|
|
207
|
+
}
|
|
208
|
+
# Include latest verdict from history if available
|
|
209
|
+
history = iteration_state.get("history", [])
|
|
210
|
+
if history:
|
|
211
|
+
data["iteration"]["latest_verdict"] = history[-1].get("verdict", "unknown")
|
|
212
|
+
|
|
213
|
+
marker.write_text(json.dumps(data), encoding="utf-8")
|
|
214
|
+
iter_info = f" (iteration {data.get('iteration', {}).get('current', '?')}/{data.get('iteration', {}).get('max', '?')})" if iteration_state else ""
|
|
215
|
+
eprint(f"[{hook_name}] Created review marker: {marker} (hash: {plan_hash}){iter_info}")
|
|
216
|
+
except Exception as e:
|
|
217
|
+
eprint(f"[{hook_name}] Warning: failed to create review marker: {e}")
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
# ---------------------------
|
|
221
|
+
# Questions offered state
|
|
222
|
+
# ---------------------------
|
|
223
|
+
|
|
224
|
+
def get_questions_marker_path(session_id: str) -> Path:
|
|
225
|
+
"""Get path to questions-offered marker file for this session."""
|
|
226
|
+
safe_id = re.sub(r'[^a-zA-Z0-9_-]', '_', session_id)[:32]
|
|
227
|
+
return Path(tempfile.gettempdir()) / f"cc-native-questions-offered-{safe_id}.json"
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def was_questions_offered(session_id: str) -> bool:
|
|
231
|
+
"""Check if clarifying questions were already offered this session.
|
|
232
|
+
|
|
233
|
+
Returns False on any error (fail-safe: allow feature to work).
|
|
234
|
+
"""
|
|
235
|
+
try:
|
|
236
|
+
marker = get_questions_marker_path(session_id)
|
|
237
|
+
return marker.exists()
|
|
238
|
+
except Exception:
|
|
239
|
+
return False
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def mark_questions_offered(session_id: str) -> bool:
|
|
243
|
+
"""Mark that questions were offered. Returns True on success.
|
|
244
|
+
|
|
245
|
+
Only stores timestamp, no user data. Returns False on error.
|
|
246
|
+
"""
|
|
247
|
+
try:
|
|
248
|
+
marker = get_questions_marker_path(session_id)
|
|
249
|
+
data = {"offered_at": datetime.now().isoformat()}
|
|
250
|
+
marker.write_text(json.dumps(data), encoding="utf-8")
|
|
251
|
+
return True
|
|
252
|
+
except Exception as e:
|
|
253
|
+
eprint(f"[utils] Failed to write questions marker: {e}")
|
|
254
|
+
return False
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
# ---------------------------
|
|
258
|
+
# JSON parsing
|
|
259
|
+
# ---------------------------
|
|
260
|
+
|
|
261
|
+
def parse_json_maybe(text: str, require_fields: Optional[List[str]] = None) -> Optional[Dict[str, Any]]:
|
|
262
|
+
"""Try strict JSON parse. If that fails, attempt to extract the first {...} block.
|
|
263
|
+
|
|
264
|
+
Args:
|
|
265
|
+
text: Raw text that may contain JSON
|
|
266
|
+
require_fields: Optional list of field names to check for in parsed result.
|
|
267
|
+
If provided and fields are missing, a warning is logged but
|
|
268
|
+
the object is still returned.
|
|
269
|
+
|
|
270
|
+
Returns:
|
|
271
|
+
Parsed dict or None if parsing failed entirely.
|
|
272
|
+
"""
|
|
273
|
+
text = text.strip()
|
|
274
|
+
if not text:
|
|
275
|
+
return None
|
|
276
|
+
|
|
277
|
+
obj: Optional[Dict[str, Any]] = None
|
|
278
|
+
parse_method = None
|
|
279
|
+
|
|
280
|
+
try:
|
|
281
|
+
parsed = json.loads(text)
|
|
282
|
+
if isinstance(parsed, dict):
|
|
283
|
+
obj = parsed
|
|
284
|
+
parse_method = "strict"
|
|
285
|
+
except Exception:
|
|
286
|
+
pass
|
|
287
|
+
|
|
288
|
+
# Heuristic: try to extract a JSON object substring
|
|
289
|
+
if obj is None:
|
|
290
|
+
start = text.find("{")
|
|
291
|
+
end = text.rfind("}")
|
|
292
|
+
if start != -1 and end != -1 and end > start:
|
|
293
|
+
candidate = text[start : end + 1]
|
|
294
|
+
try:
|
|
295
|
+
parsed = json.loads(candidate)
|
|
296
|
+
if isinstance(parsed, dict):
|
|
297
|
+
obj = parsed
|
|
298
|
+
parse_method = "heuristic"
|
|
299
|
+
eprint(f"[parse] Used heuristic extraction (chars {start}-{end})")
|
|
300
|
+
except Exception:
|
|
301
|
+
eprint(f"[parse] Heuristic extraction failed for candidate at chars {start}-{end}")
|
|
302
|
+
return None
|
|
303
|
+
|
|
304
|
+
# If we parsed something, validate required fields
|
|
305
|
+
if obj and require_fields:
|
|
306
|
+
missing = [f for f in require_fields if f not in obj or not obj[f]]
|
|
307
|
+
if missing:
|
|
308
|
+
eprint(f"[parse] WARNING: parsed JSON ({parse_method}) missing/empty fields: {missing}")
|
|
309
|
+
eprint(f"[parse] Keys present: {list(obj.keys())}")
|
|
310
|
+
|
|
311
|
+
return obj
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def coerce_to_review(obj: Optional[Dict[str, Any]], default_fix_msg: str = "Retry or check configuration.") -> Tuple[bool, str, Dict[str, Any]]:
|
|
315
|
+
"""Validate/normalize to our expected structure.
|
|
316
|
+
|
|
317
|
+
Returns:
|
|
318
|
+
Tuple of (ok, verdict, normalized_data).
|
|
319
|
+
normalized_data includes 'summary_source' field: 'reviewer' if summary was provided,
|
|
320
|
+
'default' if it was defaulted due to missing/empty summary.
|
|
321
|
+
"""
|
|
322
|
+
if not obj:
|
|
323
|
+
eprint("[coerce] WARNING: No object provided to coerce_to_review")
|
|
324
|
+
return False, "error", {
|
|
325
|
+
"verdict": "fail",
|
|
326
|
+
"summary": "No structured output returned.",
|
|
327
|
+
"summary_source": "default",
|
|
328
|
+
"issues": [{"severity": "high", "category": "tooling", "issue": "Reviewer returned no JSON.", "suggested_fix": default_fix_msg}],
|
|
329
|
+
"missing_sections": [],
|
|
330
|
+
"questions": [],
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
verdict = obj.get("verdict")
|
|
334
|
+
if verdict not in ("pass", "warn", "fail"):
|
|
335
|
+
eprint(f"[coerce] WARNING: Invalid or missing verdict '{verdict}', defaulting to 'warn'")
|
|
336
|
+
verdict = "warn"
|
|
337
|
+
|
|
338
|
+
# Log when fields are being defaulted
|
|
339
|
+
summary_raw = str(obj.get("summary", "")).strip()
|
|
340
|
+
if not summary_raw:
|
|
341
|
+
eprint("[coerce] WARNING: summary missing or empty from parsed output, using default")
|
|
342
|
+
# Add diagnostic output
|
|
343
|
+
eprint(f"[coerce] Raw object keys: {list(obj.keys()) if obj else 'None'}")
|
|
344
|
+
if obj:
|
|
345
|
+
eprint(f"[coerce] verdict={obj.get('verdict')}, issues_count={len(obj.get('issues', []))}")
|
|
346
|
+
if not obj.get("issues"):
|
|
347
|
+
eprint("[coerce] INFO: issues array empty or missing")
|
|
348
|
+
|
|
349
|
+
norm = {
|
|
350
|
+
"verdict": verdict,
|
|
351
|
+
"summary": summary_raw or "No summary provided.",
|
|
352
|
+
"summary_source": "reviewer" if summary_raw else "default",
|
|
353
|
+
"issues": obj.get("issues") if isinstance(obj.get("issues"), list) else [],
|
|
354
|
+
"missing_sections": obj.get("missing_sections") if isinstance(obj.get("missing_sections"), list) else [],
|
|
355
|
+
"questions": obj.get("questions") if isinstance(obj.get("questions"), list) else [],
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
return True, verdict, norm
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def worst_verdict(verdicts: List[str]) -> str:
|
|
362
|
+
"""Return the worst verdict from a list."""
|
|
363
|
+
order = {"pass": 0, "warn": 1, "fail": 2, "skip": 0, "error": 1}
|
|
364
|
+
worst = "pass"
|
|
365
|
+
for v in verdicts:
|
|
366
|
+
if order.get(v, 1) > order.get(worst, 0):
|
|
367
|
+
worst = v
|
|
368
|
+
if worst == "error":
|
|
369
|
+
return "warn"
|
|
370
|
+
return worst
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
# ---------------------------
|
|
374
|
+
# Artifact writing
|
|
375
|
+
# ---------------------------
|
|
376
|
+
|
|
377
|
+
def find_plan_file() -> Optional[str]:
|
|
378
|
+
"""Find the most recent plan file in ~/.claude/plans/."""
|
|
379
|
+
plans_dir = Path.home() / ".claude" / "plans"
|
|
380
|
+
if not plans_dir.exists():
|
|
381
|
+
return None
|
|
382
|
+
plan_files = list(plans_dir.glob("*.md"))
|
|
383
|
+
if not plan_files:
|
|
384
|
+
return None
|
|
385
|
+
plan_files.sort(key=lambda p: p.stat().st_mtime, reverse=True)
|
|
386
|
+
return str(plan_files[0])
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def get_state_path_from_plan(plan_path: str) -> Path:
|
|
390
|
+
"""Derive state file path from plan file path.
|
|
391
|
+
|
|
392
|
+
The state file is stored adjacent to the plan file with a .state.json extension.
|
|
393
|
+
This prevents state loss when session IDs change or temp files are cleaned up.
|
|
394
|
+
|
|
395
|
+
Example: ~/.claude/plans/foo.md -> ~/.claude/plans/foo.state.json
|
|
396
|
+
"""
|
|
397
|
+
plan_file = Path(plan_path)
|
|
398
|
+
return plan_file.with_suffix('.state.json')
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def load_state(plan_path: str) -> Optional[Dict[str, Any]]:
|
|
402
|
+
"""Load state file for this plan if it exists."""
|
|
403
|
+
state_file = get_state_path_from_plan(plan_path)
|
|
404
|
+
|
|
405
|
+
if not state_file.exists():
|
|
406
|
+
return None
|
|
407
|
+
|
|
408
|
+
try:
|
|
409
|
+
return json.loads(state_file.read_text(encoding="utf-8"))
|
|
410
|
+
except Exception as e:
|
|
411
|
+
eprint(f"[utils] Failed to read state file: {e}")
|
|
412
|
+
return None
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def save_state(plan_path: str, state: Dict[str, Any]) -> bool:
|
|
416
|
+
"""Save state file for this plan.
|
|
417
|
+
|
|
418
|
+
Returns True on success, False on failure.
|
|
419
|
+
"""
|
|
420
|
+
state_file = get_state_path_from_plan(plan_path)
|
|
421
|
+
try:
|
|
422
|
+
state_file.write_text(json.dumps(state, indent=2), encoding="utf-8")
|
|
423
|
+
return True
|
|
424
|
+
except Exception as e:
|
|
425
|
+
eprint(f"[utils] Failed to save state file: {e}")
|
|
426
|
+
return False
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def delete_state(plan_path: str) -> bool:
|
|
430
|
+
"""Delete state file after successful archive.
|
|
431
|
+
|
|
432
|
+
Returns True if deleted or didn't exist, False on error.
|
|
433
|
+
"""
|
|
434
|
+
state_file = get_state_path_from_plan(plan_path)
|
|
435
|
+
try:
|
|
436
|
+
if state_file.exists():
|
|
437
|
+
state_file.unlink()
|
|
438
|
+
eprint(f"[utils] Deleted state file: {state_file}")
|
|
439
|
+
return True
|
|
440
|
+
except Exception as e:
|
|
441
|
+
eprint(f"[utils] Warning: failed to delete state file: {e}")
|
|
442
|
+
return False
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def format_review_markdown(
|
|
446
|
+
results: List[ReviewerResult],
|
|
447
|
+
overall: str,
|
|
448
|
+
title: str = "CC-Native Plan Review",
|
|
449
|
+
settings: Optional[Dict[str, Any]] = None,
|
|
450
|
+
) -> str:
|
|
451
|
+
"""Format review results as markdown."""
|
|
452
|
+
display = DEFAULT_DISPLAY.copy()
|
|
453
|
+
if settings:
|
|
454
|
+
display = settings.get("display", DEFAULT_DISPLAY)
|
|
455
|
+
|
|
456
|
+
max_issues = display.get("maxIssues", 12)
|
|
457
|
+
max_missing = display.get("maxMissingSections", 12)
|
|
458
|
+
max_questions = display.get("maxQuestions", 12)
|
|
459
|
+
|
|
460
|
+
lines: List[str] = []
|
|
461
|
+
lines.append(f"# {title}\n")
|
|
462
|
+
lines.append(f"**Overall verdict:** `{overall.upper()}`\n")
|
|
463
|
+
|
|
464
|
+
for r in results:
|
|
465
|
+
lines.append(f"## {r.name.title() if r.name.islower() else r.name}\n")
|
|
466
|
+
lines.append(f"- ok: `{r.ok}`")
|
|
467
|
+
lines.append(f"- verdict: `{r.verdict}`")
|
|
468
|
+
if r.data:
|
|
469
|
+
summary = r.data.get('summary', '').strip()
|
|
470
|
+
if r.data.get('summary_source') == 'default':
|
|
471
|
+
lines.append(f"- summary: ⚠️ {summary} *(reviewer did not return summary)*")
|
|
472
|
+
else:
|
|
473
|
+
lines.append(f"- summary: {summary}")
|
|
474
|
+
issues = r.data.get("issues", [])
|
|
475
|
+
if issues:
|
|
476
|
+
lines.append("\n### Issues")
|
|
477
|
+
for it in issues[:max_issues]:
|
|
478
|
+
sev = it.get("severity", "medium")
|
|
479
|
+
cat = it.get("category", "general")
|
|
480
|
+
issue = it.get("issue", "")
|
|
481
|
+
fix = it.get("suggested_fix", "")
|
|
482
|
+
lines.append(f"- **[{sev}] {cat}**: {issue}\n - fix: {fix}")
|
|
483
|
+
missing = r.data.get("missing_sections", [])
|
|
484
|
+
if missing:
|
|
485
|
+
lines.append("\n### Missing Sections")
|
|
486
|
+
for m in missing[:max_missing]:
|
|
487
|
+
lines.append(f"- {m}")
|
|
488
|
+
qs = r.data.get("questions", [])
|
|
489
|
+
if qs:
|
|
490
|
+
lines.append("\n### Questions")
|
|
491
|
+
for q in qs[:max_questions]:
|
|
492
|
+
lines.append(f"- {q}")
|
|
493
|
+
else:
|
|
494
|
+
lines.append(f"- note: {r.err or 'no structured output'}")
|
|
495
|
+
lines.append("")
|
|
496
|
+
|
|
497
|
+
return "\n".join(lines).strip() + "\n"
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def write_review_artifacts(
|
|
501
|
+
base: Path,
|
|
502
|
+
plan: str,
|
|
503
|
+
md: str,
|
|
504
|
+
results: List[ReviewerResult],
|
|
505
|
+
payload: Dict[str, Any],
|
|
506
|
+
subdir: str = "reviews",
|
|
507
|
+
) -> Path:
|
|
508
|
+
"""Write review artifacts to _output/cc-native/plans/{subdir}/."""
|
|
509
|
+
ts = now_local()
|
|
510
|
+
date_folder = ts.strftime("%Y-%m-%d")
|
|
511
|
+
time_part = ts.strftime("%H%M%S")
|
|
512
|
+
sid = sanitize_filename(str(payload.get("session_id", "unknown")))
|
|
513
|
+
|
|
514
|
+
out_dir = base / "_output" / "cc-native" / "plans" / subdir / date_folder
|
|
515
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
516
|
+
|
|
517
|
+
plan_path = out_dir / f"{time_part}-session-{sid}-plan.md"
|
|
518
|
+
review_path = out_dir / f"{time_part}-session-{sid}-review.md"
|
|
519
|
+
|
|
520
|
+
plan_path.write_text(plan, encoding="utf-8")
|
|
521
|
+
review_path.write_text(md, encoding="utf-8")
|
|
522
|
+
|
|
523
|
+
for r in results:
|
|
524
|
+
if r.data:
|
|
525
|
+
(out_dir / f"{time_part}-session-{sid}-{r.name}.json").write_text(
|
|
526
|
+
json.dumps(r.data, indent=2, ensure_ascii=False),
|
|
527
|
+
encoding="utf-8",
|
|
528
|
+
)
|
|
529
|
+
|
|
530
|
+
return review_path
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
@dataclass
|
|
534
|
+
class OrchestratorResult:
|
|
535
|
+
"""Result from the plan orchestrator."""
|
|
536
|
+
complexity: str # simple | medium | high
|
|
537
|
+
category: str # code | infrastructure | documentation | life | business | design | research
|
|
538
|
+
selected_agents: List[str]
|
|
539
|
+
reasoning: str
|
|
540
|
+
skip_reason: Optional[str] = None
|
|
541
|
+
error: Optional[str] = None
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
@dataclass
|
|
545
|
+
class CombinedReviewResult:
|
|
546
|
+
"""Combined result from all review phases."""
|
|
547
|
+
plan_hash: str
|
|
548
|
+
overall_verdict: str
|
|
549
|
+
cli_reviewers: Dict[str, ReviewerResult]
|
|
550
|
+
orchestration: Optional[OrchestratorResult]
|
|
551
|
+
agents: Dict[str, ReviewerResult]
|
|
552
|
+
timestamp: str
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
def format_combined_markdown(
|
|
556
|
+
result: CombinedReviewResult,
|
|
557
|
+
settings: Optional[Dict[str, Any]] = None,
|
|
558
|
+
) -> str:
|
|
559
|
+
"""Format combined review result as a single markdown document."""
|
|
560
|
+
display = DEFAULT_DISPLAY.copy()
|
|
561
|
+
if settings:
|
|
562
|
+
display = settings.get("display", DEFAULT_DISPLAY)
|
|
563
|
+
|
|
564
|
+
max_issues = display.get("maxIssues", 12)
|
|
565
|
+
max_missing = display.get("maxMissingSections", 12)
|
|
566
|
+
max_questions = display.get("maxQuestions", 12)
|
|
567
|
+
|
|
568
|
+
lines: List[str] = []
|
|
569
|
+
lines.append("# CC-Native Plan Review\n")
|
|
570
|
+
lines.append(f"**Overall Verdict:** `{result.overall_verdict.upper()}`")
|
|
571
|
+
lines.append(f"**Plan Hash:** `{result.plan_hash}`\n")
|
|
572
|
+
lines.append("---\n")
|
|
573
|
+
|
|
574
|
+
# CLI Reviewers section
|
|
575
|
+
if result.cli_reviewers:
|
|
576
|
+
lines.append("## CLI Reviewers\n")
|
|
577
|
+
for name, r in result.cli_reviewers.items():
|
|
578
|
+
lines.append(f"### {name.title()}\n")
|
|
579
|
+
lines.append(f"- verdict: `{r.verdict}`")
|
|
580
|
+
if r.data:
|
|
581
|
+
summary = r.data.get('summary', '').strip()
|
|
582
|
+
if r.data.get('summary_source') == 'default':
|
|
583
|
+
lines.append(f"- summary: ⚠️ {summary} *(reviewer did not return summary)*")
|
|
584
|
+
else:
|
|
585
|
+
lines.append(f"- summary: {summary}")
|
|
586
|
+
_append_review_details(lines, r.data, max_issues, max_missing, max_questions)
|
|
587
|
+
elif r.err:
|
|
588
|
+
lines.append(f"- error: {r.err}")
|
|
589
|
+
lines.append("")
|
|
590
|
+
|
|
591
|
+
# Orchestration section
|
|
592
|
+
if result.orchestration:
|
|
593
|
+
lines.append("---\n")
|
|
594
|
+
lines.append("## Orchestration\n")
|
|
595
|
+
lines.append(f"- **Complexity:** `{result.orchestration.complexity}`")
|
|
596
|
+
lines.append(f"- **Category:** `{result.orchestration.category}`")
|
|
597
|
+
agents_str = ", ".join(result.orchestration.selected_agents) if result.orchestration.selected_agents else "None"
|
|
598
|
+
lines.append(f"- **Agents Selected:** {agents_str}")
|
|
599
|
+
lines.append(f"- **Reasoning:** {result.orchestration.reasoning}")
|
|
600
|
+
if result.orchestration.skip_reason:
|
|
601
|
+
lines.append(f"- **Skip Reason:** {result.orchestration.skip_reason}")
|
|
602
|
+
if result.orchestration.error:
|
|
603
|
+
lines.append(f"- **Error:** {result.orchestration.error}")
|
|
604
|
+
lines.append("")
|
|
605
|
+
|
|
606
|
+
# Agent Reviews section
|
|
607
|
+
if result.agents:
|
|
608
|
+
lines.append("---\n")
|
|
609
|
+
lines.append("## Agent Reviews\n")
|
|
610
|
+
for name, r in result.agents.items():
|
|
611
|
+
lines.append(f"### {name}\n")
|
|
612
|
+
lines.append(f"- verdict: `{r.verdict}`")
|
|
613
|
+
if r.data:
|
|
614
|
+
summary = r.data.get('summary', '').strip()
|
|
615
|
+
if r.data.get('summary_source') == 'default':
|
|
616
|
+
lines.append(f"- summary: ⚠️ {summary} *(reviewer did not return summary)*")
|
|
617
|
+
else:
|
|
618
|
+
lines.append(f"- summary: {summary}")
|
|
619
|
+
_append_review_details(lines, r.data, max_issues, max_missing, max_questions)
|
|
620
|
+
elif r.err:
|
|
621
|
+
lines.append(f"- error: {r.err}")
|
|
622
|
+
lines.append("")
|
|
623
|
+
|
|
624
|
+
return "\n".join(lines).strip() + "\n"
|
|
625
|
+
|
|
626
|
+
|
|
627
|
+
def _append_review_details(
|
|
628
|
+
lines: List[str],
|
|
629
|
+
data: Dict[str, Any],
|
|
630
|
+
max_issues: int,
|
|
631
|
+
max_missing: int,
|
|
632
|
+
max_questions: int
|
|
633
|
+
) -> None:
|
|
634
|
+
"""Append issue details to markdown lines."""
|
|
635
|
+
issues = data.get("issues", [])
|
|
636
|
+
if issues:
|
|
637
|
+
lines.append("\n**Issues:**")
|
|
638
|
+
for it in issues[:max_issues]:
|
|
639
|
+
sev = it.get("severity", "medium")
|
|
640
|
+
cat = it.get("category", "general")
|
|
641
|
+
issue = it.get("issue", "")
|
|
642
|
+
fix = it.get("suggested_fix", "")
|
|
643
|
+
lines.append(f"- **[{sev}] {cat}**: {issue}")
|
|
644
|
+
if fix:
|
|
645
|
+
lines.append(f" - fix: {fix}")
|
|
646
|
+
|
|
647
|
+
missing = data.get("missing_sections", [])
|
|
648
|
+
if missing:
|
|
649
|
+
lines.append("\n**Missing Sections:**")
|
|
650
|
+
for m in missing[:max_missing]:
|
|
651
|
+
lines.append(f"- {m}")
|
|
652
|
+
|
|
653
|
+
qs = data.get("questions", [])
|
|
654
|
+
if qs:
|
|
655
|
+
lines.append("\n**Questions:**")
|
|
656
|
+
for q in qs[:max_questions]:
|
|
657
|
+
lines.append(f"- {q}")
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
def build_combined_json(result: CombinedReviewResult) -> Dict[str, Any]:
|
|
661
|
+
"""Build combined JSON output structure."""
|
|
662
|
+
output: Dict[str, Any] = {
|
|
663
|
+
"metadata": {
|
|
664
|
+
"timestamp": result.timestamp,
|
|
665
|
+
"plan_hash": result.plan_hash,
|
|
666
|
+
},
|
|
667
|
+
"overall": {
|
|
668
|
+
"verdict": result.overall_verdict,
|
|
669
|
+
},
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
# CLI reviewers
|
|
673
|
+
if result.cli_reviewers:
|
|
674
|
+
output["cliReviewers"] = {}
|
|
675
|
+
for name, r in result.cli_reviewers.items():
|
|
676
|
+
output["cliReviewers"][name] = {
|
|
677
|
+
"verdict": r.verdict,
|
|
678
|
+
"summary": r.data.get("summary") if r.data else None,
|
|
679
|
+
"summarySource": r.data.get("summary_source") if r.data else None,
|
|
680
|
+
"issues": r.data.get("issues", []) if r.data else [],
|
|
681
|
+
"ok": r.ok,
|
|
682
|
+
"error": r.err if r.err else None,
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
# Orchestration
|
|
686
|
+
if result.orchestration:
|
|
687
|
+
output["orchestration"] = {
|
|
688
|
+
"complexity": result.orchestration.complexity,
|
|
689
|
+
"category": result.orchestration.category,
|
|
690
|
+
"selectedAgents": result.orchestration.selected_agents,
|
|
691
|
+
"reasoning": result.orchestration.reasoning,
|
|
692
|
+
"skipReason": result.orchestration.skip_reason,
|
|
693
|
+
"error": result.orchestration.error,
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
# Agents
|
|
697
|
+
if result.agents:
|
|
698
|
+
output["agents"] = {}
|
|
699
|
+
for name, r in result.agents.items():
|
|
700
|
+
output["agents"][name] = {
|
|
701
|
+
"verdict": r.verdict,
|
|
702
|
+
"summary": r.data.get("summary") if r.data else None,
|
|
703
|
+
"summarySource": r.data.get("summary_source") if r.data else None,
|
|
704
|
+
"issues": r.data.get("issues", []) if r.data else [],
|
|
705
|
+
"missing_sections": r.data.get("missing_sections", []) if r.data else [],
|
|
706
|
+
"questions": r.data.get("questions", []) if r.data else [],
|
|
707
|
+
"ok": r.ok,
|
|
708
|
+
"error": r.err if r.err else None,
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
return output
|
|
712
|
+
|
|
713
|
+
|
|
714
|
+
def write_combined_artifacts(
|
|
715
|
+
base: Path,
|
|
716
|
+
plan: str,
|
|
717
|
+
result: CombinedReviewResult,
|
|
718
|
+
payload: Dict[str, Any],
|
|
719
|
+
settings: Optional[Dict[str, Any]] = None,
|
|
720
|
+
context_reviews_dir: Optional[Path] = None,
|
|
721
|
+
) -> Path:
|
|
722
|
+
"""Write combined review artifacts to context reviews folder.
|
|
723
|
+
|
|
724
|
+
Args:
|
|
725
|
+
base: Project base directory
|
|
726
|
+
plan: Plan content
|
|
727
|
+
result: Combined review result
|
|
728
|
+
payload: Hook payload
|
|
729
|
+
settings: Display settings
|
|
730
|
+
context_reviews_dir: Reviews directory from context system (required)
|
|
731
|
+
|
|
732
|
+
Raises:
|
|
733
|
+
ValueError: If context_reviews_dir is not provided
|
|
734
|
+
"""
|
|
735
|
+
if not context_reviews_dir:
|
|
736
|
+
raise ValueError("context_reviews_dir is required")
|
|
737
|
+
|
|
738
|
+
out_dir = context_reviews_dir
|
|
739
|
+
eprint(f"[utils] Using context reviews dir: {out_dir}")
|
|
740
|
+
|
|
741
|
+
# Check directory creation explicitly
|
|
742
|
+
try:
|
|
743
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
744
|
+
except PermissionError as e:
|
|
745
|
+
eprint(f"[utils] FATAL: Cannot create directory {out_dir}: {e}")
|
|
746
|
+
raise
|
|
747
|
+
|
|
748
|
+
# JSON write with atomic operation
|
|
749
|
+
json_path = out_dir / "review.json"
|
|
750
|
+
json_data = build_combined_json(result)
|
|
751
|
+
try:
|
|
752
|
+
if ENABLE_ROBUST_PLAN_WRITES:
|
|
753
|
+
success, error = atomic_write(json_path, json.dumps(json_data, indent=2, ensure_ascii=False))
|
|
754
|
+
if not success:
|
|
755
|
+
raise IOError(f"Atomic write failed: {error}")
|
|
756
|
+
else:
|
|
757
|
+
json_path.write_text(json.dumps(json_data, indent=2, ensure_ascii=False), encoding="utf-8")
|
|
758
|
+
except Exception as e:
|
|
759
|
+
eprint(f"[utils] FATAL: Failed to write {json_path.name}: {e}")
|
|
760
|
+
raise
|
|
761
|
+
|
|
762
|
+
# Markdown write with atomic operation
|
|
763
|
+
md_path = out_dir / "review.md"
|
|
764
|
+
md_content = format_combined_markdown(result, settings)
|
|
765
|
+
try:
|
|
766
|
+
if ENABLE_ROBUST_PLAN_WRITES:
|
|
767
|
+
success, error = atomic_write(md_path, md_content)
|
|
768
|
+
if not success:
|
|
769
|
+
raise IOError(f"Atomic write failed: {error}")
|
|
770
|
+
else:
|
|
771
|
+
md_path.write_text(md_content, encoding="utf-8")
|
|
772
|
+
except Exception as e:
|
|
773
|
+
eprint(f"[utils] FATAL: Failed to write {md_path.name}: {e}")
|
|
774
|
+
raise
|
|
775
|
+
|
|
776
|
+
# Individual reviewer writes (non-critical - continue on failure)
|
|
777
|
+
for name, r in result.cli_reviewers.items():
|
|
778
|
+
if r.data:
|
|
779
|
+
reviewer_path = out_dir / f"{name}.json"
|
|
780
|
+
try:
|
|
781
|
+
content = json.dumps(r.data, indent=2, ensure_ascii=False)
|
|
782
|
+
if ENABLE_ROBUST_PLAN_WRITES:
|
|
783
|
+
success, error = atomic_write(reviewer_path, content)
|
|
784
|
+
if not success:
|
|
785
|
+
eprint(f"[utils] WARNING: Failed to write {reviewer_path.name}: {error}")
|
|
786
|
+
else:
|
|
787
|
+
reviewer_path.write_text(content, encoding="utf-8")
|
|
788
|
+
except Exception as e:
|
|
789
|
+
eprint(f"[utils] WARNING: Failed to write {reviewer_path.name}: {e}")
|
|
790
|
+
# Continue - individual reviewer failures not critical
|
|
791
|
+
for name, r in result.agents.items():
|
|
792
|
+
if r.data:
|
|
793
|
+
reviewer_path = out_dir / f"{sanitize_filename(name)}.json"
|
|
794
|
+
try:
|
|
795
|
+
content = json.dumps(r.data, indent=2, ensure_ascii=False)
|
|
796
|
+
if ENABLE_ROBUST_PLAN_WRITES:
|
|
797
|
+
success, error = atomic_write(reviewer_path, content)
|
|
798
|
+
if not success:
|
|
799
|
+
eprint(f"[utils] WARNING: Failed to write {reviewer_path.name}: {error}")
|
|
800
|
+
else:
|
|
801
|
+
reviewer_path.write_text(content, encoding="utf-8")
|
|
802
|
+
except Exception as e:
|
|
803
|
+
eprint(f"[utils] WARNING: Failed to write {reviewer_path.name}: {e}")
|
|
804
|
+
# Continue - individual reviewer failures not critical
|
|
805
|
+
|
|
806
|
+
return md_path
|
|
807
|
+
|
|
808
|
+
|
|
809
|
+
# ---------------------------
|
|
810
|
+
# Settings loading
|
|
811
|
+
# ---------------------------
|
|
812
|
+
|
|
813
|
+
def load_config(project_dir: Path) -> Dict[str, Any]:
|
|
814
|
+
"""Load full CC-Native config from _cc-native/plan-review.config.json."""
|
|
815
|
+
settings_path = project_dir / "_cc-native" / "plan-review.config.json"
|
|
816
|
+
if not settings_path.exists():
|
|
817
|
+
return {}
|
|
818
|
+
try:
|
|
819
|
+
with open(settings_path, "r", encoding="utf-8") as f:
|
|
820
|
+
return json.load(f)
|
|
821
|
+
except Exception as e:
|
|
822
|
+
eprint(f"[cc-native] Failed to load config: {e}")
|
|
823
|
+
return {}
|
|
824
|
+
|
|
825
|
+
|
|
826
|
+
def get_display_settings(config: Dict[str, Any], section: str) -> Dict[str, int]:
|
|
827
|
+
"""Get display settings, checking section-specific first, then root."""
|
|
828
|
+
section_display = config.get(section, {}).get("display", {})
|
|
829
|
+
root_display = config.get("display", DEFAULT_DISPLAY)
|
|
830
|
+
return {**DEFAULT_DISPLAY, **root_display, **section_display}
|