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.
Files changed (204) hide show
  1. package/README.md +1248 -0
  2. package/bin/dev.cmd +3 -0
  3. package/bin/dev.js +16 -0
  4. package/bin/run.cmd +3 -0
  5. package/bin/run.js +19 -0
  6. package/dist/commands/branch.d.ts +45 -0
  7. package/dist/commands/branch.js +488 -0
  8. package/dist/commands/clean.d.ts +34 -0
  9. package/dist/commands/clean.js +186 -0
  10. package/dist/commands/clear.d.ts +51 -0
  11. package/dist/commands/clear.js +835 -0
  12. package/dist/commands/init/index.d.ts +107 -0
  13. package/dist/commands/init/index.js +565 -0
  14. package/dist/commands/launch.d.ts +21 -0
  15. package/dist/commands/launch.js +108 -0
  16. package/dist/index.d.ts +1 -0
  17. package/dist/index.js +1 -0
  18. package/dist/lib/base-command.d.ts +114 -0
  19. package/dist/lib/base-command.js +153 -0
  20. package/dist/lib/bmad-installer.d.ts +38 -0
  21. package/dist/lib/bmad-installer.js +145 -0
  22. package/dist/lib/claude-settings-types.d.ts +102 -0
  23. package/dist/lib/claude-settings-types.js +5 -0
  24. package/dist/lib/config.d.ts +25 -0
  25. package/dist/lib/config.js +46 -0
  26. package/dist/lib/debug.d.ts +39 -0
  27. package/dist/lib/debug.js +74 -0
  28. package/dist/lib/env-compat.d.ts +26 -0
  29. package/dist/lib/env-compat.js +35 -0
  30. package/dist/lib/errors.d.ts +126 -0
  31. package/dist/lib/errors.js +145 -0
  32. package/dist/lib/generic-merge.d.ts +74 -0
  33. package/dist/lib/generic-merge.js +105 -0
  34. package/dist/lib/git/branch.d.ts +67 -0
  35. package/dist/lib/git/branch.js +155 -0
  36. package/dist/lib/git/index.d.ts +11 -0
  37. package/dist/lib/git/index.js +13 -0
  38. package/dist/lib/git/safety-checks.d.ts +44 -0
  39. package/dist/lib/git/safety-checks.js +102 -0
  40. package/dist/lib/git/types.d.ts +31 -0
  41. package/dist/lib/git/types.js +6 -0
  42. package/dist/lib/git/worktree.d.ts +67 -0
  43. package/dist/lib/git/worktree.js +220 -0
  44. package/dist/lib/gitignore-manager.d.ts +10 -0
  45. package/dist/lib/gitignore-manager.js +60 -0
  46. package/dist/lib/hooks-merger.d.ts +28 -0
  47. package/dist/lib/hooks-merger.js +94 -0
  48. package/dist/lib/ide-path-resolver.d.ts +102 -0
  49. package/dist/lib/ide-path-resolver.js +129 -0
  50. package/dist/lib/index.d.ts +13 -0
  51. package/dist/lib/index.js +22 -0
  52. package/dist/lib/output.d.ts +51 -0
  53. package/dist/lib/output.js +76 -0
  54. package/dist/lib/paths.d.ts +66 -0
  55. package/dist/lib/paths.js +136 -0
  56. package/dist/lib/quiet.d.ts +12 -0
  57. package/dist/lib/quiet.js +17 -0
  58. package/dist/lib/settings-hierarchy.d.ts +42 -0
  59. package/dist/lib/settings-hierarchy.js +105 -0
  60. package/dist/lib/spawn.d.ts +105 -0
  61. package/dist/lib/spawn.js +157 -0
  62. package/dist/lib/spinner.d.ts +19 -0
  63. package/dist/lib/spinner.js +34 -0
  64. package/dist/lib/stdin.d.ts +48 -0
  65. package/dist/lib/stdin.js +60 -0
  66. package/dist/lib/template-installer.d.ts +92 -0
  67. package/dist/lib/template-installer.js +375 -0
  68. package/dist/lib/template-linter.d.ts +49 -0
  69. package/dist/lib/template-linter.js +173 -0
  70. package/dist/lib/template-merger.d.ts +47 -0
  71. package/dist/lib/template-merger.js +173 -0
  72. package/dist/lib/template-resolver.d.ts +20 -0
  73. package/dist/lib/template-resolver.js +60 -0
  74. package/dist/lib/terminal.d.ts +102 -0
  75. package/dist/lib/terminal.js +245 -0
  76. package/dist/lib/tty-detection.d.ts +62 -0
  77. package/dist/lib/tty-detection.js +83 -0
  78. package/dist/lib/user-utils.d.ts +5 -0
  79. package/dist/lib/user-utils.js +23 -0
  80. package/dist/lib/version.d.ts +99 -0
  81. package/dist/lib/version.js +144 -0
  82. package/dist/lib/watch-templates.d.ts +6 -0
  83. package/dist/lib/watch-templates.js +73 -0
  84. package/dist/lib/windsurf-hooks-hierarchy.d.ts +30 -0
  85. package/dist/lib/windsurf-hooks-hierarchy.js +66 -0
  86. package/dist/lib/windsurf-hooks-merger.d.ts +26 -0
  87. package/dist/lib/windsurf-hooks-merger.js +53 -0
  88. package/dist/lib/windsurf-hooks-types.d.ts +33 -0
  89. package/dist/lib/windsurf-hooks-types.js +5 -0
  90. package/dist/templates/CLAUDE.md +174 -0
  91. package/dist/templates/_shared/.claude/commands/handoff.md +14 -0
  92. package/dist/templates/_shared/.claude/settings.json +61 -0
  93. package/dist/templates/_shared/.codex/workflows/handoff.md +14 -0
  94. package/dist/templates/_shared/.windsurf/workflows/handoff.md +14 -0
  95. package/dist/templates/_shared/hooks/__init__.py +16 -0
  96. package/dist/templates/_shared/hooks/archive_plan.py +270 -0
  97. package/dist/templates/_shared/hooks/context_enforcer.py +621 -0
  98. package/dist/templates/_shared/hooks/context_monitor.py +322 -0
  99. package/dist/templates/_shared/hooks/file-suggestion.py +188 -0
  100. package/dist/templates/_shared/hooks/task_create_capture.py +194 -0
  101. package/dist/templates/_shared/hooks/task_update_capture.py +254 -0
  102. package/dist/templates/_shared/hooks/user_prompt_submit.py +157 -0
  103. package/dist/templates/_shared/lib/__init__.py +1 -0
  104. package/dist/templates/_shared/lib/base/__init__.py +49 -0
  105. package/dist/templates/_shared/lib/base/__pycache__/constants.cpython-313.pyc +0 -0
  106. package/dist/templates/_shared/lib/base/atomic_write.py +180 -0
  107. package/dist/templates/_shared/lib/base/constants.py +299 -0
  108. package/dist/templates/_shared/lib/base/inference.py +189 -0
  109. package/dist/templates/_shared/lib/base/utils.py +216 -0
  110. package/dist/templates/_shared/lib/context/__init__.py +119 -0
  111. package/dist/templates/_shared/lib/context/__pycache__/__init__.cpython-313.pyc +0 -0
  112. package/dist/templates/_shared/lib/context/__pycache__/cache.cpython-313.pyc +0 -0
  113. package/dist/templates/_shared/lib/context/__pycache__/context_manager.cpython-313.pyc +0 -0
  114. package/dist/templates/_shared/lib/context/__pycache__/event_log.cpython-313.pyc +0 -0
  115. package/dist/templates/_shared/lib/context/cache.py +446 -0
  116. package/dist/templates/_shared/lib/context/context_manager.py +1171 -0
  117. package/dist/templates/_shared/lib/context/discovery.py +486 -0
  118. package/dist/templates/_shared/lib/context/event_log.py +308 -0
  119. package/dist/templates/_shared/lib/context/plan_archive.py +247 -0
  120. package/dist/templates/_shared/lib/context/task_sync.py +367 -0
  121. package/dist/templates/_shared/lib/handoff/__init__.py +22 -0
  122. package/dist/templates/_shared/lib/handoff/document_generator.py +307 -0
  123. package/dist/templates/_shared/lib/templates/README.md +215 -0
  124. package/dist/templates/_shared/lib/templates/__init__.py +40 -0
  125. package/dist/templates/_shared/lib/templates/formatters.py +147 -0
  126. package/dist/templates/_shared/lib/templates/plan_context.py +119 -0
  127. package/dist/templates/_shared/scripts/save_handoff.py +99 -0
  128. package/dist/templates/_shared/workflows/handoff.md +212 -0
  129. package/dist/templates/cc-native/.claude/agents/cc-native/ACCESSIBILITY-TESTER.md +80 -0
  130. package/dist/templates/cc-native/.claude/agents/cc-native/ARCHITECT-REVIEWER.md +75 -0
  131. package/dist/templates/cc-native/.claude/agents/cc-native/ASSUMPTION-CHAIN-TRACER.md +239 -0
  132. package/dist/templates/cc-native/.claude/agents/cc-native/CLARITY-AUDITOR.md +109 -0
  133. package/dist/templates/cc-native/.claude/agents/cc-native/CODE-REVIEWER.md +71 -0
  134. package/dist/templates/cc-native/.claude/agents/cc-native/COMPLETENESS-CHECKER.md +104 -0
  135. package/dist/templates/cc-native/.claude/agents/cc-native/CONTEXT-EXTRACTOR.md +93 -0
  136. package/dist/templates/cc-native/.claude/agents/cc-native/DEVILS-ADVOCATE.md +223 -0
  137. package/dist/templates/cc-native/.claude/agents/cc-native/DOCUMENTATION-REVIEWER.md +73 -0
  138. package/dist/templates/cc-native/.claude/agents/cc-native/FEASIBILITY-ANALYST.md +93 -0
  139. package/dist/templates/cc-native/.claude/agents/cc-native/FRESH-PERSPECTIVE.md +103 -0
  140. package/dist/templates/cc-native/.claude/agents/cc-native/HANDOFF-READINESS.md +145 -0
  141. package/dist/templates/cc-native/.claude/agents/cc-native/HIDDEN-COMPLEXITY-DETECTOR.md +248 -0
  142. package/dist/templates/cc-native/.claude/agents/cc-native/INCENTIVE-MAPPER.md +235 -0
  143. package/dist/templates/cc-native/.claude/agents/cc-native/PENETRATION-TESTER.md +80 -0
  144. package/dist/templates/cc-native/.claude/agents/cc-native/PERFORMANCE-ENGINEER.md +76 -0
  145. package/dist/templates/cc-native/.claude/agents/cc-native/PLAN-ORCHESTRATOR.md +141 -0
  146. package/dist/templates/cc-native/.claude/agents/cc-native/PRECEDENT-FINDER.md +240 -0
  147. package/dist/templates/cc-native/.claude/agents/cc-native/REVERSIBILITY-ANALYST.md +211 -0
  148. package/dist/templates/cc-native/.claude/agents/cc-native/RISK-ASSESSOR.md +101 -0
  149. package/dist/templates/cc-native/.claude/agents/cc-native/SECOND-ORDER-ANALYST.md +197 -0
  150. package/dist/templates/cc-native/.claude/agents/cc-native/SIMPLICITY-GUARDIAN.md +97 -0
  151. package/dist/templates/cc-native/.claude/agents/cc-native/SKEPTIC.md +349 -0
  152. package/dist/templates/cc-native/.claude/agents/cc-native/STAKEHOLDER-ADVOCATE.md +106 -0
  153. package/dist/templates/cc-native/.claude/agents/cc-native/TRADE-OFF-ILLUMINATOR.md +205 -0
  154. package/dist/templates/cc-native/.claude/commands/cc-native/fresh-perspective.md +8 -0
  155. package/dist/templates/cc-native/.claude/commands/cc-native/specdev.md +10 -0
  156. package/dist/templates/cc-native/.claude/settings.json +119 -0
  157. package/dist/templates/cc-native/.windsurf/workflows/cc-native/fix.md +8 -0
  158. package/dist/templates/cc-native/.windsurf/workflows/cc-native/fresh-perspective.md +8 -0
  159. package/dist/templates/cc-native/.windsurf/workflows/cc-native/implement.md +8 -0
  160. package/dist/templates/cc-native/.windsurf/workflows/cc-native/research.md +8 -0
  161. package/dist/templates/cc-native/CC-NATIVE-README.md +192 -0
  162. package/dist/templates/cc-native/MIGRATION.md +86 -0
  163. package/dist/templates/cc-native/TEMPLATE-SCHEMA.md +331 -0
  164. package/dist/templates/cc-native/_cc-native/docs/PERMISSION_REQUEST_VERIFICATION.md +147 -0
  165. package/dist/templates/cc-native/_cc-native/hooks/__pycache__/add_plan_context.cpython-313.pyc +0 -0
  166. package/dist/templates/cc-native/_cc-native/hooks/__pycache__/archive_plan.cpython-313.pyc +0 -0
  167. package/dist/templates/cc-native/_cc-native/hooks/__pycache__/cc-native-agent-review.cpython-313.pyc +0 -0
  168. package/dist/templates/cc-native/_cc-native/hooks/__pycache__/cc-native-plan-review.cpython-313.pyc +0 -0
  169. package/dist/templates/cc-native/_cc-native/hooks/__pycache__/test_permission_request.cpython-313.pyc +0 -0
  170. package/dist/templates/cc-native/_cc-native/hooks/add_plan_context.py +150 -0
  171. package/dist/templates/cc-native/_cc-native/hooks/cc-native-plan-review.py +746 -0
  172. package/dist/templates/cc-native/_cc-native/hooks/suggest-fresh-perspective.py +339 -0
  173. package/dist/templates/cc-native/_cc-native/lib/__init__.py +57 -0
  174. package/dist/templates/cc-native/_cc-native/lib/__pycache__/__init__.cpython-313.pyc +0 -0
  175. package/dist/templates/cc-native/_cc-native/lib/__pycache__/orchestrator.cpython-313.pyc +0 -0
  176. package/dist/templates/cc-native/_cc-native/lib/__pycache__/state.cpython-313.pyc +0 -0
  177. package/dist/templates/cc-native/_cc-native/lib/__pycache__/utils.cpython-313.pyc +0 -0
  178. package/dist/templates/cc-native/_cc-native/lib/async_archive.py +68 -0
  179. package/dist/templates/cc-native/_cc-native/lib/atomic_write.py +98 -0
  180. package/dist/templates/cc-native/_cc-native/lib/constants.py +45 -0
  181. package/dist/templates/cc-native/_cc-native/lib/orchestrator.py +273 -0
  182. package/dist/templates/cc-native/_cc-native/lib/reviewers/__init__.py +28 -0
  183. package/dist/templates/cc-native/_cc-native/lib/reviewers/__pycache__/__init__.cpython-313.pyc +0 -0
  184. package/dist/templates/cc-native/_cc-native/lib/reviewers/__pycache__/agent.cpython-313.pyc +0 -0
  185. package/dist/templates/cc-native/_cc-native/lib/reviewers/__pycache__/base.cpython-313.pyc +0 -0
  186. package/dist/templates/cc-native/_cc-native/lib/reviewers/__pycache__/codex.cpython-313.pyc +0 -0
  187. package/dist/templates/cc-native/_cc-native/lib/reviewers/__pycache__/gemini.cpython-313.pyc +0 -0
  188. package/dist/templates/cc-native/_cc-native/lib/reviewers/agent.py +164 -0
  189. package/dist/templates/cc-native/_cc-native/lib/reviewers/base.py +89 -0
  190. package/dist/templates/cc-native/_cc-native/lib/reviewers/codex.py +119 -0
  191. package/dist/templates/cc-native/_cc-native/lib/reviewers/gemini.py +103 -0
  192. package/dist/templates/cc-native/_cc-native/lib/state.py +251 -0
  193. package/dist/templates/cc-native/_cc-native/lib/utils.py +830 -0
  194. package/dist/templates/cc-native/_cc-native/plan-review.config.json +76 -0
  195. package/dist/templates/cc-native/_cc-native/scripts/__pycache__/aggregate_agents.cpython-313.pyc +0 -0
  196. package/dist/templates/cc-native/_cc-native/scripts/aggregate_agents.py +151 -0
  197. package/dist/templates/cc-native/_cc-native/workflows/fresh-perspective.md +134 -0
  198. package/dist/templates/cc-native/_cc-native/workflows/specdev.md +9 -0
  199. package/dist/types/exit-codes.d.ts +11 -0
  200. package/dist/types/exit-codes.js +10 -0
  201. package/dist/types/index.d.ts +5 -0
  202. package/dist/types/index.js +7 -0
  203. package/oclif.manifest.json +405 -0
  204. 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}