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.
- package/bin/run.js +1 -1
- package/dist/commands/clear.js +28 -131
- package/dist/commands/init/index.js +3 -3
- package/dist/lib/gitignore-manager.d.ts +32 -0
- package/dist/lib/gitignore-manager.js +141 -2
- package/dist/templates/CLAUDE.md +8 -8
- package/dist/templates/_shared/.claude/commands/handoff-resume.md +64 -0
- package/dist/templates/_shared/.claude/commands/handoff.md +16 -10
- package/dist/templates/_shared/.claude/settings.json +7 -7
- package/dist/templates/_shared/hooks-ts/_utils/git-state.ts +2 -0
- package/dist/templates/_shared/hooks-ts/archive_plan.ts +159 -0
- package/dist/templates/_shared/hooks-ts/context_monitor.ts +147 -0
- package/dist/templates/_shared/hooks-ts/file-suggestion.ts +130 -0
- package/dist/templates/_shared/hooks-ts/pre_compact.ts +49 -0
- package/dist/templates/_shared/hooks-ts/session_end.ts +104 -0
- package/dist/templates/_shared/hooks-ts/session_start.ts +144 -0
- package/dist/templates/_shared/hooks-ts/task_create_capture.ts +48 -0
- package/dist/templates/_shared/hooks-ts/task_update_capture.ts +74 -0
- package/dist/templates/_shared/hooks-ts/user_prompt_submit.ts +83 -0
- package/dist/templates/_shared/lib-ts/CLAUDE.md +318 -0
- package/dist/templates/_shared/lib-ts/base/atomic-write.ts +12 -12
- package/dist/templates/_shared/lib-ts/base/constants.ts +22 -15
- package/dist/templates/_shared/lib-ts/base/hook-utils.ts +129 -50
- package/dist/templates/_shared/lib-ts/base/inference.ts +28 -21
- package/dist/templates/_shared/lib-ts/base/logger.ts +31 -15
- package/dist/templates/_shared/lib-ts/base/state-io.ts +9 -7
- package/dist/templates/_shared/lib-ts/base/stop-words.ts +131 -131
- package/dist/templates/_shared/lib-ts/base/subprocess-utils.ts +139 -0
- package/dist/templates/_shared/lib-ts/base/utils.ts +69 -69
- package/dist/templates/_shared/lib-ts/context/context-formatter.ts +30 -24
- package/dist/templates/_shared/lib-ts/context/context-selector.ts +50 -32
- package/dist/templates/_shared/lib-ts/context/context-store.ts +76 -48
- package/dist/templates/_shared/lib-ts/context/plan-manager.ts +61 -37
- package/dist/templates/_shared/lib-ts/context/task-tracker.ts +10 -6
- package/dist/templates/_shared/lib-ts/handoff/document-generator.ts +11 -10
- package/dist/templates/_shared/lib-ts/handoff/handoff-reader.ts +159 -0
- package/dist/templates/_shared/lib-ts/templates/formatters.ts +6 -4
- package/dist/templates/_shared/lib-ts/types.ts +68 -55
- package/dist/templates/_shared/scripts/resolve_context.ts +24 -0
- package/dist/templates/_shared/scripts/resume_handoff.ts +321 -0
- package/dist/templates/_shared/scripts/save_handoff.ts +21 -21
- package/dist/templates/_shared/scripts/status_line.ts +733 -0
- package/dist/templates/cc-native/.claude/settings.json +175 -185
- package/dist/templates/cc-native/TEMPLATE-SCHEMA.md +15 -17
- package/dist/templates/cc-native/_cc-native/agents/CLAUDE.md +0 -2
- package/dist/templates/cc-native/_cc-native/hooks/CLAUDE.md +109 -135
- package/dist/templates/cc-native/_cc-native/hooks/add_plan_context.ts +119 -0
- package/dist/templates/cc-native/_cc-native/hooks/cc-native-plan-review.ts +921 -0
- package/dist/templates/cc-native/_cc-native/hooks/plan_questions_early.ts +61 -0
- package/dist/templates/cc-native/_cc-native/lib-ts/aggregate-agents.ts +157 -0
- package/dist/templates/cc-native/_cc-native/lib-ts/artifacts.ts +709 -0
- package/dist/templates/cc-native/_cc-native/lib-ts/cc-native-state.ts +199 -0
- package/dist/templates/cc-native/_cc-native/lib-ts/cli-output-parser.ts +124 -0
- package/dist/templates/cc-native/_cc-native/lib-ts/config.ts +57 -0
- package/dist/templates/cc-native/_cc-native/lib-ts/constants.ts +83 -0
- package/dist/templates/cc-native/_cc-native/lib-ts/debug.ts +80 -0
- package/dist/templates/cc-native/_cc-native/lib-ts/index.ts +119 -0
- package/dist/templates/cc-native/_cc-native/lib-ts/json-parser.ts +162 -0
- package/dist/templates/cc-native/_cc-native/lib-ts/nul +3 -0
- package/dist/templates/cc-native/_cc-native/lib-ts/orchestrator.ts +249 -0
- package/dist/templates/cc-native/_cc-native/lib-ts/reviewers/agent.ts +155 -0
- package/dist/templates/cc-native/_cc-native/lib-ts/reviewers/codex.ts +130 -0
- package/dist/templates/cc-native/_cc-native/lib-ts/reviewers/gemini.ts +106 -0
- package/dist/templates/cc-native/_cc-native/lib-ts/reviewers/index.ts +10 -0
- package/dist/templates/cc-native/_cc-native/lib-ts/reviewers/types.ts +23 -0
- package/dist/templates/cc-native/_cc-native/lib-ts/state.ts +243 -0
- package/dist/templates/cc-native/_cc-native/lib-ts/tsconfig.json +18 -0
- package/dist/templates/cc-native/_cc-native/lib-ts/types.ts +310 -0
- package/dist/templates/cc-native/_cc-native/lib-ts/verdict.ts +72 -0
- package/dist/templates/cc-native/_cc-native/plan-review.config.json +1 -9
- package/oclif.manifest.json +1 -1
- package/package.json +1 -1
- package/dist/templates/_shared/hooks/__init__.py +0 -16
- package/dist/templates/_shared/hooks/__pycache__/__init__.cpython-313.pyc +0 -0
- package/dist/templates/_shared/hooks/__pycache__/archive_plan.cpython-313.pyc +0 -0
- package/dist/templates/_shared/hooks/__pycache__/context_enforcer.cpython-313.pyc +0 -0
- package/dist/templates/_shared/hooks/__pycache__/context_monitor.cpython-313.pyc +0 -0
- package/dist/templates/_shared/hooks/__pycache__/file-suggestion.cpython-313.pyc +0 -0
- package/dist/templates/_shared/hooks/__pycache__/pre_compact.cpython-313.pyc +0 -0
- package/dist/templates/_shared/hooks/__pycache__/session_end.cpython-313.pyc +0 -0
- package/dist/templates/_shared/hooks/__pycache__/session_start.cpython-313.pyc +0 -0
- package/dist/templates/_shared/hooks/__pycache__/task_create_atomicity.cpython-313.pyc +0 -0
- package/dist/templates/_shared/hooks/__pycache__/task_create_capture.cpython-313.pyc +0 -0
- package/dist/templates/_shared/hooks/__pycache__/task_update_capture.cpython-313.pyc +0 -0
- package/dist/templates/_shared/hooks/__pycache__/user_prompt_submit.cpython-313.pyc +0 -0
- package/dist/templates/_shared/hooks/archive_plan.py +0 -177
- package/dist/templates/_shared/hooks/context_monitor.py +0 -270
- package/dist/templates/_shared/hooks/file-suggestion.py +0 -215
- package/dist/templates/_shared/hooks/pre_compact.py +0 -104
- package/dist/templates/_shared/hooks/session_end.py +0 -173
- package/dist/templates/_shared/hooks/session_start.py +0 -206
- package/dist/templates/_shared/hooks/task_create_capture.py +0 -108
- package/dist/templates/_shared/hooks/task_update_capture.py +0 -145
- package/dist/templates/_shared/hooks/user_prompt_submit.py +0 -139
- package/dist/templates/_shared/lib/__init__.py +0 -1
- package/dist/templates/_shared/lib/__pycache__/__init__.cpython-313.pyc +0 -0
- package/dist/templates/_shared/lib/base/__init__.py +0 -65
- package/dist/templates/_shared/lib/base/__pycache__/__init__.cpython-313.pyc +0 -0
- package/dist/templates/_shared/lib/base/__pycache__/atomic_write.cpython-313.pyc +0 -0
- package/dist/templates/_shared/lib/base/__pycache__/constants.cpython-313.pyc +0 -0
- package/dist/templates/_shared/lib/base/__pycache__/hook_utils.cpython-313.pyc +0 -0
- package/dist/templates/_shared/lib/base/__pycache__/inference.cpython-313.pyc +0 -0
- package/dist/templates/_shared/lib/base/__pycache__/logger.cpython-313.pyc +0 -0
- package/dist/templates/_shared/lib/base/__pycache__/stop_words.cpython-313.pyc +0 -0
- package/dist/templates/_shared/lib/base/__pycache__/subprocess_utils.cpython-313.pyc +0 -0
- package/dist/templates/_shared/lib/base/__pycache__/utils.cpython-313.pyc +0 -0
- package/dist/templates/_shared/lib/base/atomic_write.py +0 -180
- package/dist/templates/_shared/lib/base/constants.py +0 -358
- package/dist/templates/_shared/lib/base/hook_utils.py +0 -339
- package/dist/templates/_shared/lib/base/inference.py +0 -307
- package/dist/templates/_shared/lib/base/logger.py +0 -305
- package/dist/templates/_shared/lib/base/stop_words.py +0 -221
- package/dist/templates/_shared/lib/base/subprocess_utils.py +0 -46
- package/dist/templates/_shared/lib/base/utils.py +0 -263
- package/dist/templates/_shared/lib/context/__init__.py +0 -102
- package/dist/templates/_shared/lib/context/__pycache__/__init__.cpython-313.pyc +0 -0
- package/dist/templates/_shared/lib/context/__pycache__/cache.cpython-313.pyc +0 -0
- package/dist/templates/_shared/lib/context/__pycache__/context_extractor.cpython-313.pyc +0 -0
- package/dist/templates/_shared/lib/context/__pycache__/context_formatter.cpython-313.pyc +0 -0
- package/dist/templates/_shared/lib/context/__pycache__/context_manager.cpython-313.pyc +0 -0
- package/dist/templates/_shared/lib/context/__pycache__/context_selector.cpython-313.pyc +0 -0
- package/dist/templates/_shared/lib/context/__pycache__/context_store.cpython-313.pyc +0 -0
- package/dist/templates/_shared/lib/context/__pycache__/discovery.cpython-313.pyc +0 -0
- package/dist/templates/_shared/lib/context/__pycache__/event_log.cpython-313.pyc +0 -0
- package/dist/templates/_shared/lib/context/__pycache__/plan_archive.cpython-313.pyc +0 -0
- package/dist/templates/_shared/lib/context/__pycache__/plan_manager.cpython-313.pyc +0 -0
- package/dist/templates/_shared/lib/context/__pycache__/task_sync.cpython-313.pyc +0 -0
- package/dist/templates/_shared/lib/context/__pycache__/task_tracker.cpython-313.pyc +0 -0
- package/dist/templates/_shared/lib/context/context_formatter.py +0 -317
- package/dist/templates/_shared/lib/context/context_selector.py +0 -508
- package/dist/templates/_shared/lib/context/context_store.py +0 -653
- package/dist/templates/_shared/lib/context/plan_manager.py +0 -303
- package/dist/templates/_shared/lib/context/task_tracker.py +0 -188
- package/dist/templates/_shared/lib/handoff/__init__.py +0 -22
- package/dist/templates/_shared/lib/handoff/__pycache__/__init__.cpython-313.pyc +0 -0
- package/dist/templates/_shared/lib/handoff/__pycache__/document_generator.cpython-313.pyc +0 -0
- package/dist/templates/_shared/lib/handoff/document_generator.py +0 -278
- package/dist/templates/_shared/lib/templates/README.md +0 -206
- package/dist/templates/_shared/lib/templates/__init__.py +0 -36
- package/dist/templates/_shared/lib/templates/__pycache__/__init__.cpython-313.pyc +0 -0
- package/dist/templates/_shared/lib/templates/__pycache__/formatters.cpython-313.pyc +0 -0
- package/dist/templates/_shared/lib/templates/__pycache__/persona_questions.cpython-313.pyc +0 -0
- package/dist/templates/_shared/lib/templates/__pycache__/plan_context.cpython-313.pyc +0 -0
- package/dist/templates/_shared/lib/templates/formatters.py +0 -146
- package/dist/templates/_shared/lib/templates/plan_context.py +0 -73
- package/dist/templates/_shared/scripts/__pycache__/save_handoff.cpython-313.pyc +0 -0
- package/dist/templates/_shared/scripts/__pycache__/status_line.cpython-313.pyc +0 -0
- package/dist/templates/_shared/scripts/save_handoff.py +0 -357
- package/dist/templates/_shared/scripts/status_line.py +0 -716
- package/dist/templates/cc-native/.claude/commands/cc-native/fresh-perspective.md +0 -8
- package/dist/templates/cc-native/.windsurf/workflows/cc-native/fresh-perspective.md +0 -8
- package/dist/templates/cc-native/MIGRATION.md +0 -86
- package/dist/templates/cc-native/_cc-native/hooks/__pycache__/add_plan_context.cpython-313.pyc +0 -0
- package/dist/templates/cc-native/_cc-native/hooks/__pycache__/cc-native-plan-review.cpython-313.pyc +0 -0
- package/dist/templates/cc-native/_cc-native/hooks/__pycache__/mark_questions_asked.cpython-313.pyc +0 -0
- package/dist/templates/cc-native/_cc-native/hooks/__pycache__/plan_accepted.cpython-313.pyc +0 -0
- package/dist/templates/cc-native/_cc-native/hooks/__pycache__/plan_questions_early.cpython-313.pyc +0 -0
- package/dist/templates/cc-native/_cc-native/hooks/__pycache__/suggest-fresh-perspective.cpython-313.pyc +0 -0
- package/dist/templates/cc-native/_cc-native/hooks/add_plan_context.py +0 -130
- package/dist/templates/cc-native/_cc-native/hooks/cc-native-plan-review.py +0 -954
- package/dist/templates/cc-native/_cc-native/hooks/plan_questions_early.py +0 -81
- package/dist/templates/cc-native/_cc-native/hooks/suggest-fresh-perspective.py +0 -340
- package/dist/templates/cc-native/_cc-native/lib/CLAUDE.md +0 -265
- package/dist/templates/cc-native/_cc-native/lib/__init__.py +0 -53
- package/dist/templates/cc-native/_cc-native/lib/__pycache__/__init__.cpython-313.pyc +0 -0
- package/dist/templates/cc-native/_cc-native/lib/__pycache__/atomic_write.cpython-313.pyc +0 -0
- package/dist/templates/cc-native/_cc-native/lib/__pycache__/constants.cpython-313.pyc +0 -0
- package/dist/templates/cc-native/_cc-native/lib/__pycache__/debug.cpython-313.pyc +0 -0
- package/dist/templates/cc-native/_cc-native/lib/__pycache__/orchestrator.cpython-313.pyc +0 -0
- package/dist/templates/cc-native/_cc-native/lib/__pycache__/state.cpython-313.pyc +0 -0
- package/dist/templates/cc-native/_cc-native/lib/__pycache__/utils.cpython-313.pyc +0 -0
- package/dist/templates/cc-native/_cc-native/lib/constants.py +0 -45
- package/dist/templates/cc-native/_cc-native/lib/debug.py +0 -139
- package/dist/templates/cc-native/_cc-native/lib/orchestrator.py +0 -362
- package/dist/templates/cc-native/_cc-native/lib/reviewers/__init__.py +0 -28
- package/dist/templates/cc-native/_cc-native/lib/reviewers/__pycache__/__init__.cpython-313.pyc +0 -0
- package/dist/templates/cc-native/_cc-native/lib/reviewers/__pycache__/agent.cpython-313.pyc +0 -0
- package/dist/templates/cc-native/_cc-native/lib/reviewers/__pycache__/base.cpython-313.pyc +0 -0
- package/dist/templates/cc-native/_cc-native/lib/reviewers/__pycache__/codex.cpython-313.pyc +0 -0
- package/dist/templates/cc-native/_cc-native/lib/reviewers/__pycache__/gemini.cpython-313.pyc +0 -0
- package/dist/templates/cc-native/_cc-native/lib/reviewers/agent.py +0 -215
- package/dist/templates/cc-native/_cc-native/lib/reviewers/base.py +0 -88
- package/dist/templates/cc-native/_cc-native/lib/reviewers/codex.py +0 -124
- package/dist/templates/cc-native/_cc-native/lib/reviewers/gemini.py +0 -108
- package/dist/templates/cc-native/_cc-native/lib/state.py +0 -268
- package/dist/templates/cc-native/_cc-native/lib/utils.py +0 -1071
- package/dist/templates/cc-native/_cc-native/scripts/__pycache__/aggregate_agents.cpython-313.pyc +0 -0
- package/dist/templates/cc-native/_cc-native/scripts/aggregate_agents.py +0 -168
- 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}
|