aiwcli 0.10.3 → 0.11.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 (189) hide show
  1. package/bin/run.js +1 -1
  2. package/dist/commands/clear.js +28 -131
  3. package/dist/commands/init/index.js +3 -3
  4. package/dist/lib/gitignore-manager.d.ts +32 -0
  5. package/dist/lib/gitignore-manager.js +141 -2
  6. package/dist/templates/CLAUDE.md +8 -8
  7. package/dist/templates/_shared/.claude/commands/handoff-resume.md +64 -0
  8. package/dist/templates/_shared/.claude/commands/handoff.md +16 -10
  9. package/dist/templates/_shared/.claude/settings.json +7 -7
  10. package/dist/templates/_shared/hooks-ts/_utils/git-state.ts +2 -0
  11. package/dist/templates/_shared/hooks-ts/archive_plan.ts +159 -0
  12. package/dist/templates/_shared/hooks-ts/context_monitor.ts +147 -0
  13. package/dist/templates/_shared/hooks-ts/file-suggestion.ts +130 -0
  14. package/dist/templates/_shared/hooks-ts/pre_compact.ts +49 -0
  15. package/dist/templates/_shared/hooks-ts/session_end.ts +104 -0
  16. package/dist/templates/_shared/hooks-ts/session_start.ts +144 -0
  17. package/dist/templates/_shared/hooks-ts/task_create_capture.ts +48 -0
  18. package/dist/templates/_shared/hooks-ts/task_update_capture.ts +74 -0
  19. package/dist/templates/_shared/hooks-ts/user_prompt_submit.ts +83 -0
  20. package/dist/templates/_shared/lib-ts/CLAUDE.md +318 -0
  21. package/dist/templates/_shared/lib-ts/base/atomic-write.ts +12 -12
  22. package/dist/templates/_shared/lib-ts/base/constants.ts +22 -15
  23. package/dist/templates/_shared/lib-ts/base/hook-utils.ts +129 -50
  24. package/dist/templates/_shared/lib-ts/base/inference.ts +28 -21
  25. package/dist/templates/_shared/lib-ts/base/logger.ts +31 -15
  26. package/dist/templates/_shared/lib-ts/base/state-io.ts +9 -7
  27. package/dist/templates/_shared/lib-ts/base/stop-words.ts +131 -131
  28. package/dist/templates/_shared/lib-ts/base/subprocess-utils.ts +139 -0
  29. package/dist/templates/_shared/lib-ts/base/utils.ts +69 -69
  30. package/dist/templates/_shared/lib-ts/context/context-formatter.ts +30 -24
  31. package/dist/templates/_shared/lib-ts/context/context-selector.ts +50 -32
  32. package/dist/templates/_shared/lib-ts/context/context-store.ts +76 -48
  33. package/dist/templates/_shared/lib-ts/context/plan-manager.ts +61 -37
  34. package/dist/templates/_shared/lib-ts/context/task-tracker.ts +10 -6
  35. package/dist/templates/_shared/lib-ts/handoff/document-generator.ts +11 -10
  36. package/dist/templates/_shared/lib-ts/handoff/handoff-reader.ts +159 -0
  37. package/dist/templates/_shared/lib-ts/templates/formatters.ts +6 -4
  38. package/dist/templates/_shared/lib-ts/types.ts +68 -55
  39. package/dist/templates/_shared/scripts/resolve_context.ts +24 -0
  40. package/dist/templates/_shared/scripts/resume_handoff.ts +321 -0
  41. package/dist/templates/_shared/scripts/save_handoff.ts +21 -21
  42. package/dist/templates/_shared/scripts/status_line.ts +733 -0
  43. package/dist/templates/cc-native/.claude/settings.json +175 -185
  44. package/dist/templates/cc-native/TEMPLATE-SCHEMA.md +15 -17
  45. package/dist/templates/cc-native/_cc-native/agents/CLAUDE.md +0 -2
  46. package/dist/templates/cc-native/_cc-native/hooks/CLAUDE.md +109 -135
  47. package/dist/templates/cc-native/_cc-native/hooks/add_plan_context.ts +119 -0
  48. package/dist/templates/cc-native/_cc-native/hooks/cc-native-plan-review.ts +921 -0
  49. package/dist/templates/cc-native/_cc-native/hooks/plan_questions_early.ts +61 -0
  50. package/dist/templates/cc-native/_cc-native/lib-ts/aggregate-agents.ts +157 -0
  51. package/dist/templates/cc-native/_cc-native/lib-ts/artifacts.ts +709 -0
  52. package/dist/templates/cc-native/_cc-native/lib-ts/cc-native-state.ts +199 -0
  53. package/dist/templates/cc-native/_cc-native/lib-ts/cli-output-parser.ts +124 -0
  54. package/dist/templates/cc-native/_cc-native/lib-ts/config.ts +57 -0
  55. package/dist/templates/cc-native/_cc-native/lib-ts/constants.ts +83 -0
  56. package/dist/templates/cc-native/_cc-native/lib-ts/debug.ts +80 -0
  57. package/dist/templates/cc-native/_cc-native/lib-ts/index.ts +119 -0
  58. package/dist/templates/cc-native/_cc-native/lib-ts/json-parser.ts +162 -0
  59. package/dist/templates/cc-native/_cc-native/lib-ts/nul +3 -0
  60. package/dist/templates/cc-native/_cc-native/lib-ts/orchestrator.ts +249 -0
  61. package/dist/templates/cc-native/_cc-native/lib-ts/reviewers/agent.ts +155 -0
  62. package/dist/templates/cc-native/_cc-native/lib-ts/reviewers/codex.ts +130 -0
  63. package/dist/templates/cc-native/_cc-native/lib-ts/reviewers/gemini.ts +106 -0
  64. package/dist/templates/cc-native/_cc-native/lib-ts/reviewers/index.ts +10 -0
  65. package/dist/templates/cc-native/_cc-native/lib-ts/reviewers/types.ts +23 -0
  66. package/dist/templates/cc-native/_cc-native/lib-ts/state.ts +243 -0
  67. package/dist/templates/cc-native/_cc-native/lib-ts/tsconfig.json +18 -0
  68. package/dist/templates/cc-native/_cc-native/lib-ts/types.ts +310 -0
  69. package/dist/templates/cc-native/_cc-native/lib-ts/verdict.ts +72 -0
  70. package/dist/templates/cc-native/_cc-native/plan-review.config.json +1 -9
  71. package/oclif.manifest.json +1 -1
  72. package/package.json +1 -1
  73. package/dist/templates/_shared/hooks/__init__.py +0 -16
  74. package/dist/templates/_shared/hooks/__pycache__/__init__.cpython-313.pyc +0 -0
  75. package/dist/templates/_shared/hooks/__pycache__/archive_plan.cpython-313.pyc +0 -0
  76. package/dist/templates/_shared/hooks/__pycache__/context_enforcer.cpython-313.pyc +0 -0
  77. package/dist/templates/_shared/hooks/__pycache__/context_monitor.cpython-313.pyc +0 -0
  78. package/dist/templates/_shared/hooks/__pycache__/file-suggestion.cpython-313.pyc +0 -0
  79. package/dist/templates/_shared/hooks/__pycache__/pre_compact.cpython-313.pyc +0 -0
  80. package/dist/templates/_shared/hooks/__pycache__/session_end.cpython-313.pyc +0 -0
  81. package/dist/templates/_shared/hooks/__pycache__/session_start.cpython-313.pyc +0 -0
  82. package/dist/templates/_shared/hooks/__pycache__/task_create_atomicity.cpython-313.pyc +0 -0
  83. package/dist/templates/_shared/hooks/__pycache__/task_create_capture.cpython-313.pyc +0 -0
  84. package/dist/templates/_shared/hooks/__pycache__/task_update_capture.cpython-313.pyc +0 -0
  85. package/dist/templates/_shared/hooks/__pycache__/user_prompt_submit.cpython-313.pyc +0 -0
  86. package/dist/templates/_shared/hooks/archive_plan.py +0 -177
  87. package/dist/templates/_shared/hooks/context_monitor.py +0 -270
  88. package/dist/templates/_shared/hooks/file-suggestion.py +0 -215
  89. package/dist/templates/_shared/hooks/pre_compact.py +0 -104
  90. package/dist/templates/_shared/hooks/session_end.py +0 -173
  91. package/dist/templates/_shared/hooks/session_start.py +0 -206
  92. package/dist/templates/_shared/hooks/task_create_capture.py +0 -108
  93. package/dist/templates/_shared/hooks/task_update_capture.py +0 -145
  94. package/dist/templates/_shared/hooks/user_prompt_submit.py +0 -139
  95. package/dist/templates/_shared/lib/__init__.py +0 -1
  96. package/dist/templates/_shared/lib/__pycache__/__init__.cpython-313.pyc +0 -0
  97. package/dist/templates/_shared/lib/base/__init__.py +0 -65
  98. package/dist/templates/_shared/lib/base/__pycache__/__init__.cpython-313.pyc +0 -0
  99. package/dist/templates/_shared/lib/base/__pycache__/atomic_write.cpython-313.pyc +0 -0
  100. package/dist/templates/_shared/lib/base/__pycache__/constants.cpython-313.pyc +0 -0
  101. package/dist/templates/_shared/lib/base/__pycache__/hook_utils.cpython-313.pyc +0 -0
  102. package/dist/templates/_shared/lib/base/__pycache__/inference.cpython-313.pyc +0 -0
  103. package/dist/templates/_shared/lib/base/__pycache__/logger.cpython-313.pyc +0 -0
  104. package/dist/templates/_shared/lib/base/__pycache__/stop_words.cpython-313.pyc +0 -0
  105. package/dist/templates/_shared/lib/base/__pycache__/subprocess_utils.cpython-313.pyc +0 -0
  106. package/dist/templates/_shared/lib/base/__pycache__/utils.cpython-313.pyc +0 -0
  107. package/dist/templates/_shared/lib/base/atomic_write.py +0 -180
  108. package/dist/templates/_shared/lib/base/constants.py +0 -358
  109. package/dist/templates/_shared/lib/base/hook_utils.py +0 -339
  110. package/dist/templates/_shared/lib/base/inference.py +0 -307
  111. package/dist/templates/_shared/lib/base/logger.py +0 -305
  112. package/dist/templates/_shared/lib/base/stop_words.py +0 -221
  113. package/dist/templates/_shared/lib/base/subprocess_utils.py +0 -46
  114. package/dist/templates/_shared/lib/base/utils.py +0 -263
  115. package/dist/templates/_shared/lib/context/__init__.py +0 -102
  116. package/dist/templates/_shared/lib/context/__pycache__/__init__.cpython-313.pyc +0 -0
  117. package/dist/templates/_shared/lib/context/__pycache__/cache.cpython-313.pyc +0 -0
  118. package/dist/templates/_shared/lib/context/__pycache__/context_extractor.cpython-313.pyc +0 -0
  119. package/dist/templates/_shared/lib/context/__pycache__/context_formatter.cpython-313.pyc +0 -0
  120. package/dist/templates/_shared/lib/context/__pycache__/context_manager.cpython-313.pyc +0 -0
  121. package/dist/templates/_shared/lib/context/__pycache__/context_selector.cpython-313.pyc +0 -0
  122. package/dist/templates/_shared/lib/context/__pycache__/context_store.cpython-313.pyc +0 -0
  123. package/dist/templates/_shared/lib/context/__pycache__/discovery.cpython-313.pyc +0 -0
  124. package/dist/templates/_shared/lib/context/__pycache__/event_log.cpython-313.pyc +0 -0
  125. package/dist/templates/_shared/lib/context/__pycache__/plan_archive.cpython-313.pyc +0 -0
  126. package/dist/templates/_shared/lib/context/__pycache__/plan_manager.cpython-313.pyc +0 -0
  127. package/dist/templates/_shared/lib/context/__pycache__/task_sync.cpython-313.pyc +0 -0
  128. package/dist/templates/_shared/lib/context/__pycache__/task_tracker.cpython-313.pyc +0 -0
  129. package/dist/templates/_shared/lib/context/context_formatter.py +0 -317
  130. package/dist/templates/_shared/lib/context/context_selector.py +0 -508
  131. package/dist/templates/_shared/lib/context/context_store.py +0 -653
  132. package/dist/templates/_shared/lib/context/plan_manager.py +0 -303
  133. package/dist/templates/_shared/lib/context/task_tracker.py +0 -188
  134. package/dist/templates/_shared/lib/handoff/__init__.py +0 -22
  135. package/dist/templates/_shared/lib/handoff/__pycache__/__init__.cpython-313.pyc +0 -0
  136. package/dist/templates/_shared/lib/handoff/__pycache__/document_generator.cpython-313.pyc +0 -0
  137. package/dist/templates/_shared/lib/handoff/document_generator.py +0 -278
  138. package/dist/templates/_shared/lib/templates/README.md +0 -206
  139. package/dist/templates/_shared/lib/templates/__init__.py +0 -36
  140. package/dist/templates/_shared/lib/templates/__pycache__/__init__.cpython-313.pyc +0 -0
  141. package/dist/templates/_shared/lib/templates/__pycache__/formatters.cpython-313.pyc +0 -0
  142. package/dist/templates/_shared/lib/templates/__pycache__/persona_questions.cpython-313.pyc +0 -0
  143. package/dist/templates/_shared/lib/templates/__pycache__/plan_context.cpython-313.pyc +0 -0
  144. package/dist/templates/_shared/lib/templates/formatters.py +0 -146
  145. package/dist/templates/_shared/lib/templates/plan_context.py +0 -73
  146. package/dist/templates/_shared/scripts/__pycache__/save_handoff.cpython-313.pyc +0 -0
  147. package/dist/templates/_shared/scripts/__pycache__/status_line.cpython-313.pyc +0 -0
  148. package/dist/templates/_shared/scripts/save_handoff.py +0 -357
  149. package/dist/templates/_shared/scripts/status_line.py +0 -716
  150. package/dist/templates/cc-native/.claude/commands/cc-native/fresh-perspective.md +0 -8
  151. package/dist/templates/cc-native/.windsurf/workflows/cc-native/fresh-perspective.md +0 -8
  152. package/dist/templates/cc-native/MIGRATION.md +0 -86
  153. package/dist/templates/cc-native/_cc-native/hooks/__pycache__/add_plan_context.cpython-313.pyc +0 -0
  154. package/dist/templates/cc-native/_cc-native/hooks/__pycache__/cc-native-plan-review.cpython-313.pyc +0 -0
  155. package/dist/templates/cc-native/_cc-native/hooks/__pycache__/mark_questions_asked.cpython-313.pyc +0 -0
  156. package/dist/templates/cc-native/_cc-native/hooks/__pycache__/plan_accepted.cpython-313.pyc +0 -0
  157. package/dist/templates/cc-native/_cc-native/hooks/__pycache__/plan_questions_early.cpython-313.pyc +0 -0
  158. package/dist/templates/cc-native/_cc-native/hooks/__pycache__/suggest-fresh-perspective.cpython-313.pyc +0 -0
  159. package/dist/templates/cc-native/_cc-native/hooks/add_plan_context.py +0 -130
  160. package/dist/templates/cc-native/_cc-native/hooks/cc-native-plan-review.py +0 -954
  161. package/dist/templates/cc-native/_cc-native/hooks/plan_questions_early.py +0 -81
  162. package/dist/templates/cc-native/_cc-native/hooks/suggest-fresh-perspective.py +0 -340
  163. package/dist/templates/cc-native/_cc-native/lib/CLAUDE.md +0 -265
  164. package/dist/templates/cc-native/_cc-native/lib/__init__.py +0 -53
  165. package/dist/templates/cc-native/_cc-native/lib/__pycache__/__init__.cpython-313.pyc +0 -0
  166. package/dist/templates/cc-native/_cc-native/lib/__pycache__/atomic_write.cpython-313.pyc +0 -0
  167. package/dist/templates/cc-native/_cc-native/lib/__pycache__/constants.cpython-313.pyc +0 -0
  168. package/dist/templates/cc-native/_cc-native/lib/__pycache__/debug.cpython-313.pyc +0 -0
  169. package/dist/templates/cc-native/_cc-native/lib/__pycache__/orchestrator.cpython-313.pyc +0 -0
  170. package/dist/templates/cc-native/_cc-native/lib/__pycache__/state.cpython-313.pyc +0 -0
  171. package/dist/templates/cc-native/_cc-native/lib/__pycache__/utils.cpython-313.pyc +0 -0
  172. package/dist/templates/cc-native/_cc-native/lib/constants.py +0 -45
  173. package/dist/templates/cc-native/_cc-native/lib/debug.py +0 -139
  174. package/dist/templates/cc-native/_cc-native/lib/orchestrator.py +0 -362
  175. package/dist/templates/cc-native/_cc-native/lib/reviewers/__init__.py +0 -28
  176. package/dist/templates/cc-native/_cc-native/lib/reviewers/__pycache__/__init__.cpython-313.pyc +0 -0
  177. package/dist/templates/cc-native/_cc-native/lib/reviewers/__pycache__/agent.cpython-313.pyc +0 -0
  178. package/dist/templates/cc-native/_cc-native/lib/reviewers/__pycache__/base.cpython-313.pyc +0 -0
  179. package/dist/templates/cc-native/_cc-native/lib/reviewers/__pycache__/codex.cpython-313.pyc +0 -0
  180. package/dist/templates/cc-native/_cc-native/lib/reviewers/__pycache__/gemini.cpython-313.pyc +0 -0
  181. package/dist/templates/cc-native/_cc-native/lib/reviewers/agent.py +0 -215
  182. package/dist/templates/cc-native/_cc-native/lib/reviewers/base.py +0 -88
  183. package/dist/templates/cc-native/_cc-native/lib/reviewers/codex.py +0 -124
  184. package/dist/templates/cc-native/_cc-native/lib/reviewers/gemini.py +0 -108
  185. package/dist/templates/cc-native/_cc-native/lib/state.py +0 -268
  186. package/dist/templates/cc-native/_cc-native/lib/utils.py +0 -1071
  187. package/dist/templates/cc-native/_cc-native/scripts/__pycache__/aggregate_agents.cpython-313.pyc +0 -0
  188. package/dist/templates/cc-native/_cc-native/scripts/aggregate_agents.py +0 -168
  189. package/dist/templates/cc-native/_cc-native/workflows/fresh-perspective.md +0 -134
@@ -1,1071 +0,0 @@
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 .constants import ENABLE_ROBUST_PLAN_WRITES
26
- except ImportError:
27
- # When imported directly via sys.path (not as a package)
28
- from constants import ENABLE_ROBUST_PLAN_WRITES
29
-
30
- # Import atomic_write from shared lib (canonical copy)
31
- try:
32
- from ...lib.base.atomic_write import atomic_write
33
- except ImportError:
34
- # Fallback for direct execution
35
- _shared_lib = Path(__file__).resolve().parent.parent.parent / "_shared" / "lib"
36
- import importlib.util
37
- _spec = importlib.util.spec_from_file_location(
38
- "atomic_write", str(_shared_lib / "base" / "atomic_write.py")
39
- )
40
- _mod = importlib.util.module_from_spec(_spec)
41
- _spec.loader.exec_module(_mod)
42
- atomic_write = _mod.atomic_write
43
-
44
- # Import canonical utilities from shared lib (with Windows bug fixes)
45
- try:
46
- from ...lib.base.utils import (
47
- eprint,
48
- now_local,
49
- project_dir,
50
- sanitize_filename,
51
- sanitize_title,
52
- )
53
- from ...lib.base.logger import log_debug, log_info, log_warn, log_error
54
- except ImportError:
55
- # Fallback for direct execution
56
- import sys
57
- from pathlib import Path
58
- _shared_lib = Path(__file__).resolve().parent.parent.parent / "_shared" / "lib"
59
- sys.path.insert(0, str(_shared_lib))
60
- from base.utils import (
61
- eprint,
62
- now_local,
63
- project_dir,
64
- sanitize_filename,
65
- sanitize_title,
66
- )
67
- from base.logger import log_debug, log_info, log_warn, log_error
68
-
69
-
70
- # ---------------------------
71
- # Constants
72
- # ---------------------------
73
-
74
- DEFAULT_DISPLAY: Dict[str, int] = {
75
- "maxIssues": 12,
76
- "maxMissingSections": 12,
77
- "maxQuestions": 12,
78
- }
79
-
80
- DEFAULT_SANITIZATION: Dict[str, int] = {
81
- "maxSessionIdLength": 32,
82
- "maxTitleLength": 50,
83
- }
84
-
85
- REVIEW_SCHEMA: Dict[str, Any] = {
86
- "type": "object",
87
- "properties": {
88
- "verdict": {"type": "string", "enum": ["pass", "warn", "fail"]},
89
- "summary": {"type": "string", "minLength": 20},
90
- "issues": {
91
- "type": "array",
92
- "items": {
93
- "type": "object",
94
- "properties": {
95
- "severity": {"type": "string", "enum": ["high", "medium", "low"]},
96
- "category": {"type": "string"},
97
- "issue": {"type": "string"},
98
- "suggested_fix": {"type": "string"},
99
- },
100
- "required": ["severity", "category", "issue", "suggested_fix"],
101
- "additionalProperties": False,
102
- },
103
- },
104
- "missing_sections": {"type": "array", "items": {"type": "string"}},
105
- "questions": {"type": "array", "items": {"type": "string"}},
106
- },
107
- "required": ["verdict", "summary", "issues", "missing_sections", "questions"],
108
- "additionalProperties": False,
109
- }
110
-
111
-
112
- # ---------------------------
113
- # Dataclasses
114
- # ---------------------------
115
-
116
- @dataclass
117
- class ReviewerResult:
118
- """Result from a plan reviewer (Codex, Gemini, or Claude agent)."""
119
- name: str
120
- ok: bool
121
- verdict: str # pass|warn|fail|error|skip
122
- data: Dict[str, Any]
123
- raw: str
124
- err: str
125
-
126
-
127
- # ---------------------------
128
- # Plan hash deduplication
129
- # ---------------------------
130
-
131
- def compute_plan_hash(plan_content: str) -> str:
132
- """Compute a hash of the plan content."""
133
- return hashlib.sha256(plan_content.encode("utf-8")).hexdigest()[:16]
134
-
135
-
136
- def get_review_marker_path(session_id: str) -> Path:
137
- """Get path to review marker file for this session."""
138
- safe_id = re.sub(r'[^a-zA-Z0-9_-]', '_', session_id)[:32]
139
- return Path(tempfile.gettempdir()) / f"cc-native-plan-reviewed-{safe_id}.json"
140
-
141
-
142
- def is_plan_already_reviewed(session_id: str, plan_hash: str) -> bool:
143
- """Check if this exact plan has already been reviewed in this session."""
144
- marker_path = get_review_marker_path(session_id)
145
- if not marker_path.exists():
146
- return False
147
- try:
148
- data = json.loads(marker_path.read_text(encoding="utf-8"))
149
- stored_hash = data.get("plan_hash", "")
150
- return stored_hash == plan_hash
151
- except Exception:
152
- return False
153
-
154
-
155
- def was_plan_previously_denied(session_id: str, plan_hash: str) -> bool:
156
- """Check if this plan hash was previously reviewed and denied.
157
-
158
- Matches any deny variant: "deny", "hook_deny_iteration", "hook_deny_final".
159
- """
160
- marker_path = get_review_marker_path(session_id)
161
- if not marker_path.exists():
162
- return False
163
- try:
164
- data = json.loads(marker_path.read_text(encoding="utf-8"))
165
- decision = data.get("decision", "")
166
- is_denied = decision == "deny" or decision.startswith("hook_deny")
167
- return data.get("plan_hash") == plan_hash and is_denied
168
- except Exception:
169
- return False
170
-
171
-
172
- def mark_plan_reviewed(
173
- session_id: str,
174
- plan_hash: str,
175
- hook_name: str = "cc-native",
176
- iteration_state: Optional[Dict[str, Any]] = None,
177
- decision: str = "allow",
178
- ) -> None:
179
- """Mark this plan as reviewed (stores hash and decision in marker file).
180
-
181
- Args:
182
- session_id: The session identifier
183
- plan_hash: Hash of the plan content
184
- hook_name: Name of the hook (for logging)
185
- iteration_state: Optional iteration state dict with current, max, verdict info
186
- decision: Review decision - "allow" or "deny"
187
- """
188
- marker = get_review_marker_path(session_id)
189
- try:
190
- data: Dict[str, Any] = {
191
- "plan_hash": plan_hash,
192
- "reviewed_at": datetime.now().isoformat(),
193
- "decision": decision,
194
- }
195
-
196
- # Include iteration info if provided
197
- if iteration_state:
198
- data["iteration"] = {
199
- "current": iteration_state.get("current", 1),
200
- "max": iteration_state.get("max", 1),
201
- "complexity": iteration_state.get("complexity", "unknown"),
202
- }
203
- # Include latest verdict from history if available
204
- history = iteration_state.get("history", [])
205
- if history:
206
- data["iteration"]["latest_verdict"] = history[-1].get("verdict", "unknown")
207
-
208
- marker.write_text(json.dumps(data), encoding="utf-8")
209
- iter_info = f" (iteration {data.get('iteration', {}).get('current', '?')}/{data.get('iteration', {}).get('max', '?')})" if iteration_state else ""
210
- log_info(hook_name, f"Created review marker: {marker} (hash: {plan_hash}){iter_info}")
211
- except Exception as e:
212
- log_warn(hook_name, f"Failed to create review marker: {e}")
213
-
214
-
215
- # ---------------------------
216
- # Questions asked state
217
- # ---------------------------
218
-
219
- def get_questions_asked_marker_path(session_id: str) -> Path:
220
- """Get path to questions-asked marker file for this session."""
221
- safe_id = re.sub(r'[^a-zA-Z0-9_-]', '_', session_id)[:32]
222
- return Path(tempfile.gettempdir()) / f"cc-native-questions-asked-{safe_id}.json"
223
-
224
-
225
- def was_questions_asked(session_id: str) -> bool:
226
- """Check if AskUserQuestion was called this session.
227
-
228
- Returns False on any error (fail-safe: allow feature to work).
229
- """
230
- try:
231
- return get_questions_asked_marker_path(session_id).exists()
232
- except Exception:
233
- return False
234
-
235
-
236
- def mark_questions_asked(session_id: str) -> bool:
237
- """Mark that AskUserQuestion was called. Returns True on success.
238
-
239
- Only stores timestamp, no user data. Returns False on error.
240
- """
241
- try:
242
- marker = get_questions_asked_marker_path(session_id)
243
- marker.write_text(json.dumps({"asked_at": datetime.now().isoformat()}), encoding="utf-8")
244
- return True
245
- except Exception as e:
246
- log_warn("utils", f"Failed to write questions-asked marker: {e}")
247
- return False
248
-
249
-
250
- # ---------------------------
251
- # JSON parsing
252
- # ---------------------------
253
-
254
- def parse_json_maybe(text: str, require_fields: Optional[List[str]] = None) -> Optional[Dict[str, Any]]:
255
- """Try strict JSON parse. If that fails, attempt to extract the first {...} block.
256
-
257
- Args:
258
- text: Raw text that may contain JSON
259
- require_fields: Optional list of field names to check for in parsed result.
260
- If provided and fields are missing, a warning is logged but
261
- the object is still returned.
262
-
263
- Returns:
264
- Parsed dict or None if parsing failed entirely.
265
- """
266
- text = text.strip()
267
- if not text:
268
- return None
269
-
270
- obj: Optional[Dict[str, Any]] = None
271
- parse_method = None
272
-
273
- try:
274
- parsed = json.loads(text)
275
- if isinstance(parsed, dict):
276
- obj = parsed
277
- parse_method = "strict"
278
- except Exception:
279
- pass
280
-
281
- # Heuristic: try to extract a JSON object substring
282
- if obj is None:
283
- start = text.find("{")
284
- end = text.rfind("}")
285
- if start != -1 and end != -1 and end > start:
286
- candidate = text[start : end + 1]
287
- try:
288
- parsed = json.loads(candidate)
289
- if isinstance(parsed, dict):
290
- obj = parsed
291
- parse_method = "heuristic"
292
- log_debug("parse", f"Used heuristic extraction (chars {start}-{end})")
293
- except Exception:
294
- log_debug("parse", f"Heuristic extraction failed for candidate at chars {start}-{end}")
295
- return None
296
-
297
- # If we parsed something, validate required fields
298
- if obj and require_fields:
299
- missing = [f for f in require_fields if f not in obj or not obj[f]]
300
- if missing:
301
- log_warn("parse", f"Parsed JSON ({parse_method}) missing/empty fields: {missing}")
302
- log_debug("parse", f"Keys present: {list(obj.keys())}")
303
-
304
- return obj
305
-
306
-
307
- def coerce_to_review(obj: Optional[Dict[str, Any]], default_fix_msg: str = "Retry or check configuration.") -> Tuple[bool, str, Dict[str, Any]]:
308
- """Validate/normalize to our expected structure.
309
-
310
- Returns:
311
- Tuple of (ok, verdict, normalized_data).
312
- normalized_data includes 'summary_source' field: 'reviewer' if summary was provided,
313
- 'default' if it was defaulted due to missing/empty summary.
314
- """
315
- if not obj:
316
- log_warn("coerce", "No object provided to coerce_to_review")
317
- return False, "error", {
318
- "verdict": "fail",
319
- "summary": "No structured output returned.",
320
- "summary_source": "default",
321
- "issues": [{"severity": "high", "category": "tooling", "issue": "Reviewer returned no JSON.", "suggested_fix": default_fix_msg}],
322
- "missing_sections": [],
323
- "questions": [],
324
- }
325
-
326
- verdict = obj.get("verdict")
327
- if verdict not in ("pass", "warn", "fail"):
328
- log_warn("coerce", f"Invalid or missing verdict '{verdict}', defaulting to 'warn'")
329
- verdict = "warn"
330
-
331
- # Log when fields are being defaulted
332
- summary_raw = str(obj.get("summary", "")).strip()
333
- if not summary_raw:
334
- log_warn("coerce", "summary missing or empty from parsed output, using default")
335
- # Add diagnostic output
336
- log_debug("coerce", f"Raw object keys: {list(obj.keys()) if obj else 'None'}")
337
- if obj:
338
- log_debug("coerce", f"verdict={obj.get('verdict')}, issues_count={len(obj.get('issues', []))}")
339
- if not obj.get("issues"):
340
- log_debug("coerce", "issues array empty or missing")
341
-
342
- norm = {
343
- "verdict": verdict,
344
- "summary": summary_raw or "No summary provided.",
345
- "summary_source": "reviewer" if summary_raw else "default",
346
- "issues": obj.get("issues") if isinstance(obj.get("issues"), list) else [],
347
- "missing_sections": obj.get("missing_sections") if isinstance(obj.get("missing_sections"), list) else [],
348
- "questions": obj.get("questions") if isinstance(obj.get("questions"), list) else [],
349
- }
350
-
351
- return True, verdict, norm
352
-
353
-
354
- def worst_verdict(verdicts: List[str]) -> str:
355
- """Return the worst verdict from a list."""
356
- order = {"pass": 0, "warn": 1, "fail": 2, "skip": 0, "error": 1}
357
- worst = "pass"
358
- for v in verdicts:
359
- if order.get(v, 1) > order.get(worst, 0):
360
- worst = v
361
- if worst == "error":
362
- return "warn"
363
- return worst
364
-
365
-
366
- def compute_review_decision(
367
- all_verdicts: List[str],
368
- warn_threshold: float = 0.5,
369
- ) -> Tuple[bool, str, float]:
370
- """Verdict aggregation: fail veto triggers a block.
371
-
372
- Per-agent high-severity override happens upstream (caller overrides
373
- individual agent verdicts to "fail" when they exceed the threshold),
374
- so this function only needs fail_veto logic.
375
-
376
- Priority order:
377
- 1. Fail Veto: Any fail -> deny (ISO 61508 zero-tolerance).
378
- 2. Acceptable: warns are informational only.
379
-
380
- Error exclusion: Detectors that produce no signal (error/skip) are excluded
381
- from the denominator. They provide no information about plan quality.
382
-
383
- Args:
384
- all_verdicts: List of verdict strings from all reviewers.
385
- warn_threshold: Kept for backward compatibility. No longer used for blocking.
386
-
387
- Returns:
388
- Tuple of (should_deny, reason, score).
389
- - should_deny: True if the plan should be denied.
390
- - reason: "fail_veto", "acceptable", or "no_signal".
391
- - score: 1.0 for deny cases, warn_ratio for informational, 0.0 for no_signal.
392
- """
393
- # Exclude non-signal verdicts
394
- signal_verdicts = [v for v in all_verdicts if v in ("pass", "warn", "fail")]
395
-
396
- if not signal_verdicts:
397
- return False, "no_signal", 0.0
398
-
399
- # Fail blocks unconditionally
400
- fail_count = signal_verdicts.count("fail")
401
- if fail_count > 0:
402
- return True, "fail_veto", 1.0
403
-
404
- # Warn ratio still computed for logging/visibility, but does NOT block
405
- warn_count = signal_verdicts.count("warn")
406
- warn_ratio = warn_count / len(signal_verdicts)
407
- return False, "acceptable", warn_ratio
408
-
409
-
410
- # ---------------------------
411
- # Artifact writing
412
- # ---------------------------
413
-
414
- def find_plan_file() -> Optional[str]:
415
- """Find the most recent plan file in ~/.claude/plans/."""
416
- plans_dir = Path.home() / ".claude" / "plans"
417
- if not plans_dir.exists():
418
- return None
419
- plan_files = list(plans_dir.glob("*.md"))
420
- if not plan_files:
421
- return None
422
- plan_files.sort(key=lambda p: p.stat().st_mtime, reverse=True)
423
- return str(plan_files[0])
424
-
425
-
426
- def get_state_path_from_plan(plan_path: str) -> Path:
427
- """Derive state file path from plan file path.
428
-
429
- The state file is stored adjacent to the plan file with a .state.json extension.
430
- This prevents state loss when session IDs change or temp files are cleaned up.
431
-
432
- Example: ~/.claude/plans/foo.md -> ~/.claude/plans/foo.state.json
433
- """
434
- plan_file = Path(plan_path)
435
- return plan_file.with_suffix('.state.json')
436
-
437
-
438
- def format_review_markdown(
439
- results: List[ReviewerResult],
440
- overall: str,
441
- title: str = "CC-Native Plan Review",
442
- settings: Optional[Dict[str, Any]] = None,
443
- ) -> str:
444
- """Format review results as markdown."""
445
- display = DEFAULT_DISPLAY.copy()
446
- if settings:
447
- display = settings.get("display", DEFAULT_DISPLAY)
448
-
449
- max_issues = display.get("maxIssues", 12)
450
- max_missing = display.get("maxMissingSections", 12)
451
- max_questions = display.get("maxQuestions", 12)
452
-
453
- lines: List[str] = []
454
- lines.append(f"# {title}\n")
455
- lines.append(f"**Overall verdict:** `{overall.upper()}`\n")
456
-
457
- for r in results:
458
- lines.append(f"## {r.name.title() if r.name.islower() else r.name}\n")
459
- lines.append(f"- ok: `{r.ok}`")
460
- lines.append(f"- verdict: `{r.verdict}`")
461
- if r.data:
462
- summary = r.data.get('summary', '').strip()
463
- if r.data.get('summary_source') == 'default':
464
- lines.append(f"- summary: ⚠️ {summary} *(reviewer did not return summary)*")
465
- else:
466
- lines.append(f"- summary: {summary}")
467
- issues = r.data.get("issues", [])
468
- if issues:
469
- lines.append("\n### Issues")
470
- for it in issues[:max_issues]:
471
- sev = it.get("severity", "medium")
472
- cat = it.get("category", "general")
473
- issue = it.get("issue", "")
474
- fix = it.get("suggested_fix", "")
475
- lines.append(f"- **[{sev}] {cat}**: {issue}\n - fix: {fix}")
476
- missing = r.data.get("missing_sections", [])
477
- if missing:
478
- lines.append("\n### Missing Sections")
479
- for m in missing[:max_missing]:
480
- lines.append(f"- {m}")
481
- qs = r.data.get("questions", [])
482
- if qs:
483
- lines.append("\n### Questions")
484
- for q in qs[:max_questions]:
485
- lines.append(f"- {q}")
486
- else:
487
- lines.append(f"- note: {r.err or 'no structured output'}")
488
- lines.append("")
489
-
490
- return "\n".join(lines).strip() + "\n"
491
-
492
-
493
- def write_review_artifacts(
494
- base: Path,
495
- plan: str,
496
- md: str,
497
- results: List[ReviewerResult],
498
- payload: Dict[str, Any],
499
- subdir: str = "reviews",
500
- ) -> Path:
501
- """Write review artifacts to _output/cc-native/plans/{subdir}/."""
502
- ts = now_local()
503
- date_folder = ts.strftime("%Y-%m-%d")
504
- time_part = ts.strftime("%H%M%S")
505
- sid = sanitize_filename(str(payload.get("session_id", "unknown")))
506
-
507
- out_dir = base / "_output" / "cc-native" / "plans" / subdir / date_folder
508
- out_dir.mkdir(parents=True, exist_ok=True)
509
-
510
- plan_path = out_dir / f"{time_part}-session-{sid}-plan.md"
511
- review_path = out_dir / f"{time_part}-session-{sid}-review.md"
512
-
513
- plan_path.write_text(plan, encoding="utf-8")
514
- review_path.write_text(md, encoding="utf-8")
515
-
516
- for r in results:
517
- if r.data:
518
- (out_dir / f"{time_part}-session-{sid}-{r.name}.json").write_text(
519
- json.dumps(r.data, indent=2, ensure_ascii=False),
520
- encoding="utf-8",
521
- )
522
-
523
- return review_path
524
-
525
-
526
- @dataclass
527
- class OrchestratorResult:
528
- """Result from the plan orchestrator."""
529
- complexity: str # simple | medium | high
530
- category: str # code | infrastructure | documentation | life | business | design | research
531
- selected_agents: List[str]
532
- reasoning: str
533
- skip_reason: Optional[str] = None
534
- error: Optional[str] = None
535
-
536
-
537
- @dataclass
538
- class CombinedReviewResult:
539
- """Combined result from all review phases."""
540
- plan_hash: str
541
- overall_verdict: str
542
- cli_reviewers: Dict[str, ReviewerResult]
543
- orchestration: Optional[OrchestratorResult]
544
- agents: Dict[str, ReviewerResult]
545
- timestamp: str
546
-
547
-
548
- def format_combined_markdown(
549
- result: CombinedReviewResult,
550
- settings: Optional[Dict[str, Any]] = None,
551
- ) -> str:
552
- """Format combined review result as a single markdown document."""
553
- display = DEFAULT_DISPLAY.copy()
554
- if settings:
555
- display = settings.get("display", DEFAULT_DISPLAY)
556
-
557
- max_issues = display.get("maxIssues", 12)
558
- max_missing = display.get("maxMissingSections", 12)
559
- max_questions = display.get("maxQuestions", 12)
560
-
561
- lines: List[str] = []
562
- lines.append("# CC-Native Plan Review\n")
563
- lines.append(f"**Overall Verdict:** `{result.overall_verdict.upper()}`")
564
- lines.append(f"**Plan Hash:** `{result.plan_hash}`\n")
565
- lines.append("---\n")
566
-
567
- # CLI Reviewers section
568
- if result.cli_reviewers:
569
- lines.append("## CLI Reviewers\n")
570
- for name, r in result.cli_reviewers.items():
571
- lines.append(f"### {name.title()}\n")
572
- lines.append(f"- verdict: `{r.verdict}`")
573
- if r.data:
574
- summary = r.data.get('summary', '').strip()
575
- if r.data.get('summary_source') == 'default':
576
- lines.append(f"- summary: ⚠️ {summary} *(reviewer did not return summary)*")
577
- else:
578
- lines.append(f"- summary: {summary}")
579
- _append_review_details(lines, r.data, max_issues, max_missing, max_questions)
580
- elif r.err:
581
- lines.append(f"- error: {r.err}")
582
- lines.append("")
583
-
584
- # Orchestration section
585
- if result.orchestration:
586
- lines.append("---\n")
587
- lines.append("## Orchestration\n")
588
- lines.append(f"- **Complexity:** `{result.orchestration.complexity}`")
589
- lines.append(f"- **Category:** `{result.orchestration.category}`")
590
- agents_str = ", ".join(result.orchestration.selected_agents) if result.orchestration.selected_agents else "None"
591
- lines.append(f"- **Agents Selected:** {agents_str}")
592
- lines.append(f"- **Reasoning:** {result.orchestration.reasoning}")
593
- if result.orchestration.skip_reason:
594
- lines.append(f"- **Skip Reason:** {result.orchestration.skip_reason}")
595
- if result.orchestration.error:
596
- lines.append(f"- **Error:** {result.orchestration.error}")
597
- lines.append("")
598
-
599
- # Agent Reviews section
600
- if result.agents:
601
- lines.append("---\n")
602
- lines.append("## Agent Reviews\n")
603
- for name, r in result.agents.items():
604
- lines.append(f"### {name}\n")
605
- lines.append(f"- verdict: `{r.verdict}`")
606
- if r.data:
607
- summary = r.data.get('summary', '').strip()
608
- if r.data.get('summary_source') == 'default':
609
- lines.append(f"- summary: ⚠️ {summary} *(reviewer did not return summary)*")
610
- else:
611
- lines.append(f"- summary: {summary}")
612
- _append_review_details(lines, r.data, max_issues, max_missing, max_questions)
613
- elif r.err:
614
- lines.append(f"- error: {r.err}")
615
- lines.append("")
616
-
617
- return "\n".join(lines).strip() + "\n"
618
-
619
-
620
- def build_inline_review_summary(
621
- combined: CombinedReviewResult,
622
- max_issues: int = 5,
623
- max_chars: int = 800,
624
- ) -> str:
625
- """Build compact inline summary of HIGH-severity review findings for additionalContext.
626
-
627
- Returns an overall verdict line plus up to 5 high-severity issues as bullet points.
628
- Per-reviewer verdicts, missing sections, and key questions are omitted from inline
629
- output (they remain in the full review artifact on disk).
630
-
631
- Args:
632
- combined: The combined review result from all reviewers.
633
- max_issues: Maximum number of high-severity issues to include.
634
- max_chars: Character budget for the summary (truncated if exceeded).
635
-
636
- Returns:
637
- Compact summary string, or empty string if no high-severity findings.
638
- """
639
- # Collect HIGH severity issues across all reviewers
640
- all_reviewers: List[ReviewerResult] = []
641
- all_reviewers.extend(combined.cli_reviewers.values())
642
- all_reviewers.extend(combined.agents.values())
643
-
644
- high_issues: List[Dict[str, Any]] = []
645
- for r in all_reviewers:
646
- if not r.data:
647
- continue
648
- for issue in r.data.get("issues", []):
649
- if issue.get("severity") == "high":
650
- high_issues.append({**issue, "_reviewer": r.name})
651
-
652
- parts: List[str] = []
653
-
654
- # Overall verdict line
655
- parts.append(f"**Plan Review: {combined.overall_verdict.upper()}**"
656
- + (f" ({len(high_issues)} high-severity issue{'s' if len(high_issues) != 1 else ''})"
657
- if high_issues else ""))
658
-
659
- # High-severity issue bullets (max 5)
660
- for issue in high_issues[:max_issues]:
661
- cat = issue.get("category", "general")
662
- text = issue.get("issue", "")
663
- fix = issue.get("suggested_fix", "")
664
- reviewer = issue.get("_reviewer", "unknown")
665
- line = f"- [{cat}] {text}"
666
- if fix:
667
- line += f" \u2192 {fix}"
668
- line += f" ({reviewer})"
669
- parts.append(line)
670
- remaining = len(high_issues) - max_issues
671
- if remaining > 0:
672
- parts.append(f" ...and {remaining} more")
673
-
674
- result = "\n".join(parts)
675
- if len(result) > max_chars:
676
- result = result[:max_chars - 3] + "..."
677
- return result
678
-
679
-
680
- def extract_top_issues_text(
681
- combined: CombinedReviewResult,
682
- max_count: int = 3,
683
- severity: str = "high",
684
- ) -> str:
685
- """Extract top issues as a compact text string for permissionDecisionReason.
686
-
687
- Collects the first matching issue from each reviewer/agent, prefixed with
688
- the reviewer name for attribution. This gives breadth across agents rather
689
- than depth from a single one.
690
-
691
- Args:
692
- combined: The combined review result.
693
- max_count: Maximum number of issues to include.
694
- severity: Severity level to filter for.
695
-
696
- Returns:
697
- Compact semicolon-separated issue text with agent attribution.
698
- """
699
- all_reviewers: List[ReviewerResult] = []
700
- all_reviewers.extend(combined.cli_reviewers.values())
701
- all_reviewers.extend(combined.agents.values())
702
-
703
- issues: List[str] = []
704
- for r in all_reviewers:
705
- if not r.data:
706
- continue
707
- for issue in r.data.get("issues", []):
708
- if issue.get("severity") == severity:
709
- text = issue.get("issue", "").strip()
710
- if text:
711
- issues.append(f"[{r.name}] {text}")
712
- break # first high issue per reviewer only
713
- if len(issues) >= max_count:
714
- break
715
-
716
- if not issues:
717
- return "Review found critical issues"
718
- return "; ".join(issues)
719
-
720
-
721
- def build_high_issues_document(combined: CombinedReviewResult) -> str:
722
- """Build a markdown document containing ONLY high-severity issues.
723
-
724
- Grouped by reviewer/agent name with issue text and suggested fix.
725
- This is the primary signal document for plan revision — high severity
726
- only, no noise from medium/low issues.
727
- """
728
- lines = ["# High-Severity Issues\n"]
729
- all_reviewers = list(combined.cli_reviewers.values()) + list(combined.agents.values())
730
-
731
- found_any = False
732
- for r in all_reviewers:
733
- if not r.data:
734
- continue
735
- high_issues = [i for i in r.data.get("issues", []) if i.get("severity") == "high"]
736
- if not high_issues:
737
- continue
738
- found_any = True
739
- lines.append(f"## {r.name} ({r.verdict})\n")
740
- for issue in high_issues:
741
- cat = issue.get("category", "general")
742
- text = issue.get("issue", "").strip()
743
- fix = issue.get("suggested_fix", "").strip()
744
- lines.append(f"- **[{cat}]** {text}")
745
- if fix:
746
- lines.append(f" - Fix: {fix}")
747
- lines.append("") # blank line between agents
748
-
749
- if not found_any:
750
- lines.append("No high-severity issues found.\n")
751
-
752
- return "\n".join(lines)
753
-
754
-
755
- def _append_review_details(
756
- lines: List[str],
757
- data: Dict[str, Any],
758
- max_issues: int,
759
- max_missing: int,
760
- max_questions: int
761
- ) -> None:
762
- """Append issue details to markdown lines."""
763
- issues = [i for i in data.get("issues", []) if i.get("severity") != "low"]
764
- if issues:
765
- lines.append("\n**Issues:**")
766
- for it in issues[:max_issues]:
767
- sev = it.get("severity", "medium")
768
- cat = it.get("category", "general")
769
- issue = it.get("issue", "")
770
- fix = it.get("suggested_fix", "")
771
- lines.append(f"- **[{sev}] {cat}**: {issue}")
772
- if fix:
773
- lines.append(f" - fix: {fix}")
774
-
775
- missing = data.get("missing_sections", [])
776
- if missing:
777
- lines.append("\n**Missing Sections:**")
778
- for m in missing[:max_missing]:
779
- lines.append(f"- {m}")
780
-
781
- qs = data.get("questions", [])
782
- if qs:
783
- lines.append("\n**Questions:**")
784
- for q in qs[:max_questions]:
785
- lines.append(f"- {q}")
786
-
787
-
788
- def build_combined_json(result: CombinedReviewResult) -> Dict[str, Any]:
789
- """Build combined JSON output structure."""
790
- output: Dict[str, Any] = {
791
- "metadata": {
792
- "timestamp": result.timestamp,
793
- "plan_hash": result.plan_hash,
794
- },
795
- "overall": {
796
- "verdict": result.overall_verdict,
797
- },
798
- }
799
-
800
- # CLI reviewers
801
- if result.cli_reviewers:
802
- output["cliReviewers"] = {}
803
- for name, r in result.cli_reviewers.items():
804
- output["cliReviewers"][name] = {
805
- "verdict": r.verdict,
806
- "summary": r.data.get("summary") if r.data else None,
807
- "summarySource": r.data.get("summary_source") if r.data else None,
808
- "issues": [i for i in r.data.get("issues", []) if i.get("severity") != "low"] if r.data else [],
809
- "ok": r.ok,
810
- "error": r.err if r.err else None,
811
- }
812
-
813
- # Orchestration
814
- if result.orchestration:
815
- output["orchestration"] = {
816
- "complexity": result.orchestration.complexity,
817
- "category": result.orchestration.category,
818
- "selectedAgents": result.orchestration.selected_agents,
819
- "reasoning": result.orchestration.reasoning,
820
- "skipReason": result.orchestration.skip_reason,
821
- "error": result.orchestration.error,
822
- }
823
-
824
- # Agents
825
- if result.agents:
826
- output["agents"] = {}
827
- for name, r in result.agents.items():
828
- output["agents"][name] = {
829
- "verdict": r.verdict,
830
- "summary": r.data.get("summary") if r.data else None,
831
- "summarySource": r.data.get("summary_source") if r.data else None,
832
- "issues": [i for i in r.data.get("issues", []) if i.get("severity") != "low"] if r.data else [],
833
- "missing_sections": r.data.get("missing_sections", []) if r.data else [],
834
- "questions": r.data.get("questions", []) if r.data else [],
835
- "ok": r.ok,
836
- "error": r.err if r.err else None,
837
- }
838
-
839
- return output
840
-
841
-
842
- def generate_review_index(
843
- result: CombinedReviewResult,
844
- iteration: Optional[int] = None,
845
- settings: Optional[Dict[str, Any]] = None,
846
- ) -> str:
847
- """Generate index.md for a review folder.
848
-
849
- Args:
850
- result: Combined review result
851
- iteration: Iteration number (1-based)
852
- settings: Display settings
853
-
854
- Returns:
855
- Markdown content for index.md
856
- """
857
- from datetime import datetime
858
- now = datetime.now()
859
-
860
- lines = [
861
- "---",
862
- "type: review",
863
- f"plan_hash: {result.plan_hash}",
864
- f"overall_verdict: {result.overall_verdict}",
865
- f"created_at: {result.timestamp}",
866
- ]
867
- if iteration:
868
- lines.append(f"iteration: {iteration}")
869
- lines.extend([
870
- "---",
871
- "",
872
- f"# Plan Review - {now.strftime('%Y-%m-%d %H:%M')}",
873
- "",
874
- f"**Overall Verdict:** `{result.overall_verdict.upper()}`",
875
- ])
876
-
877
- if iteration:
878
- lines.append(f"**Iteration:** {iteration}")
879
-
880
- lines.extend([
881
- f"**Plan Hash:** `{result.plan_hash}`",
882
- "",
883
- ])
884
-
885
- # Summary from orchestrator
886
- if result.orchestration:
887
- lines.extend([
888
- "## Analysis",
889
- f"- **Complexity:** `{result.orchestration.complexity}`",
890
- f"- **Category:** `{result.orchestration.category}`",
891
- f"- **Reasoning:** {result.orchestration.reasoning}",
892
- "",
893
- ])
894
-
895
- # Navigation table
896
- lines.extend([
897
- "## Review Files",
898
- "",
899
- "| File | Description |",
900
- "|------|-------------|",
901
- "| [combined.md](./combined.md) | Full review details |",
902
- "| [combined.json](./combined.json) | Structured review data |",
903
- ])
904
-
905
- # CLI reviewers
906
- for name in result.cli_reviewers.keys():
907
- lines.append(f"| [{name}.json](./{name}.json) | {name.title()} reviewer output |")
908
-
909
- # Agent reviewers
910
- for name in result.agents.keys():
911
- safe_name = sanitize_filename(name)
912
- lines.append(f"| [{safe_name}.json](./{safe_name}.json) | {name} agent output |")
913
-
914
- lines.extend([
915
- "",
916
- "## Verdicts Summary",
917
- "",
918
- "| Reviewer | Verdict |",
919
- "|----------|---------|",
920
- ])
921
-
922
- for name, r in result.cli_reviewers.items():
923
- lines.append(f"| {name.title()} | `{r.verdict}` |")
924
- for name, r in result.agents.items():
925
- lines.append(f"| {name} | `{r.verdict}` |")
926
-
927
- lines.append("")
928
-
929
- return '\n'.join(lines)
930
-
931
-
932
- def write_combined_artifacts(
933
- base: Path,
934
- plan: str,
935
- result: CombinedReviewResult,
936
- payload: Dict[str, Any],
937
- settings: Optional[Dict[str, Any]] = None,
938
- context_reviews_dir: Optional[Path] = None,
939
- review_folder: Optional[Path] = None,
940
- iteration: Optional[int] = None,
941
- ) -> Path:
942
- """Write combined review artifacts to context reviews folder.
943
-
944
- Args:
945
- base: Project base directory
946
- plan: Plan content
947
- result: Combined review result
948
- payload: Hook payload
949
- settings: Display settings
950
- context_reviews_dir: Reviews directory from context system (deprecated, use review_folder)
951
- review_folder: Specific folder to write to (takes precedence)
952
- iteration: Iteration number for index generation
953
-
954
- Raises:
955
- ValueError: If neither context_reviews_dir nor review_folder is provided
956
- """
957
- # Support both old and new API
958
- out_dir = review_folder or context_reviews_dir
959
- if not out_dir:
960
- raise ValueError("Either context_reviews_dir or review_folder is required")
961
-
962
- log_debug("utils", f"Using review folder: {out_dir}")
963
-
964
- # Check directory creation explicitly
965
- try:
966
- out_dir.mkdir(parents=True, exist_ok=True)
967
- except PermissionError as e:
968
- log_error("utils", f"Cannot create directory {out_dir}: {e}")
969
- raise
970
-
971
- # JSON write with atomic operation - use combined.json for folder-based
972
- json_filename = "combined.json" if review_folder else "review.json"
973
- json_path = out_dir / json_filename
974
- json_data = build_combined_json(result)
975
- try:
976
- if ENABLE_ROBUST_PLAN_WRITES:
977
- success, error = atomic_write(json_path, json.dumps(json_data, indent=2, ensure_ascii=False))
978
- if not success:
979
- raise IOError(f"Atomic write failed: {error}")
980
- else:
981
- json_path.write_text(json.dumps(json_data, indent=2, ensure_ascii=False), encoding="utf-8")
982
- except Exception as e:
983
- log_error("utils", f"Failed to write {json_path.name}: {e}")
984
- raise
985
-
986
- # Markdown write with atomic operation - use combined.md for folder-based
987
- md_filename = "combined.md" if review_folder else "review.md"
988
- md_path = out_dir / md_filename
989
- md_content = format_combined_markdown(result, settings)
990
- try:
991
- if ENABLE_ROBUST_PLAN_WRITES:
992
- success, error = atomic_write(md_path, md_content)
993
- if not success:
994
- raise IOError(f"Atomic write failed: {error}")
995
- else:
996
- md_path.write_text(md_content, encoding="utf-8")
997
- except Exception as e:
998
- log_error("utils", f"Failed to write {md_path.name}: {e}")
999
- raise
1000
-
1001
- # Individual reviewer writes (non-critical - continue on failure)
1002
- for name, r in result.cli_reviewers.items():
1003
- if r.data:
1004
- reviewer_path = out_dir / f"{name}.json"
1005
- try:
1006
- content = json.dumps(r.data, indent=2, ensure_ascii=False)
1007
- if ENABLE_ROBUST_PLAN_WRITES:
1008
- success, error = atomic_write(reviewer_path, content)
1009
- if not success:
1010
- log_warn("utils", f"Failed to write {reviewer_path.name}: {error}")
1011
- else:
1012
- reviewer_path.write_text(content, encoding="utf-8")
1013
- except Exception as e:
1014
- log_warn("utils", f"Failed to write {reviewer_path.name}: {e}")
1015
- # Continue - individual reviewer failures not critical
1016
- for name, r in result.agents.items():
1017
- if r.data:
1018
- reviewer_path = out_dir / f"{sanitize_filename(name)}.json"
1019
- try:
1020
- content = json.dumps(r.data, indent=2, ensure_ascii=False)
1021
- if ENABLE_ROBUST_PLAN_WRITES:
1022
- success, error = atomic_write(reviewer_path, content)
1023
- if not success:
1024
- log_warn("utils", f"Failed to write {reviewer_path.name}: {error}")
1025
- else:
1026
- reviewer_path.write_text(content, encoding="utf-8")
1027
- except Exception as e:
1028
- log_warn("utils", f"Failed to write {reviewer_path.name}: {e}")
1029
- # Continue - individual reviewer failures not critical
1030
-
1031
- # Generate index.md for folder-based reviews
1032
- if review_folder:
1033
- index_content = generate_review_index(result, iteration, settings)
1034
- index_path = out_dir / "index.md"
1035
- try:
1036
- if ENABLE_ROBUST_PLAN_WRITES:
1037
- success, error = atomic_write(index_path, index_content)
1038
- if not success:
1039
- log_warn("utils", f"Failed to write index.md: {error}")
1040
- else:
1041
- index_path.write_text(index_content, encoding="utf-8")
1042
- except Exception as e:
1043
- log_warn("utils", f"Failed to write index.md: {e}")
1044
-
1045
- return index_path
1046
-
1047
- return md_path
1048
-
1049
-
1050
- # ---------------------------
1051
- # Settings loading
1052
- # ---------------------------
1053
-
1054
- def load_config(project_dir: Path) -> Dict[str, Any]:
1055
- """Load full CC-Native config from _cc-native/plan-review.config.json."""
1056
- settings_path = project_dir / "_cc-native" / "plan-review.config.json"
1057
- if not settings_path.exists():
1058
- return {}
1059
- try:
1060
- with open(settings_path, "r", encoding="utf-8") as f:
1061
- return json.load(f)
1062
- except Exception as e:
1063
- log_warn("cc-native", f"Failed to load config: {e}")
1064
- return {}
1065
-
1066
-
1067
- def get_display_settings(config: Dict[str, Any], section: str) -> Dict[str, int]:
1068
- """Get display settings, checking section-specific first, then root."""
1069
- section_display = config.get(section, {}).get("display", {})
1070
- root_display = config.get("display", DEFAULT_DISPLAY)
1071
- return {**DEFAULT_DISPLAY, **root_display, **section_display}