aiwcli 0.10.3 → 0.11.1

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 (191) 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 +107 -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/git-state.ts +1 -1
  24. package/dist/templates/_shared/lib-ts/base/hook-utils.ts +129 -50
  25. package/dist/templates/_shared/lib-ts/base/inference.ts +28 -21
  26. package/dist/templates/_shared/lib-ts/base/logger.ts +15 -2
  27. package/dist/templates/_shared/lib-ts/base/state-io.ts +9 -7
  28. package/dist/templates/_shared/lib-ts/base/stop-words.ts +131 -131
  29. package/dist/templates/_shared/lib-ts/base/subprocess-utils.ts +142 -0
  30. package/dist/templates/_shared/lib-ts/base/utils.ts +69 -69
  31. package/dist/templates/_shared/lib-ts/context/context-formatter.ts +30 -24
  32. package/dist/templates/_shared/lib-ts/context/context-selector.ts +50 -32
  33. package/dist/templates/_shared/lib-ts/context/context-store.ts +76 -48
  34. package/dist/templates/_shared/lib-ts/context/plan-manager.ts +43 -23
  35. package/dist/templates/_shared/lib-ts/context/task-tracker.ts +10 -6
  36. package/dist/templates/_shared/lib-ts/handoff/document-generator.ts +11 -10
  37. package/dist/templates/_shared/lib-ts/handoff/handoff-reader.ts +158 -0
  38. package/dist/templates/_shared/lib-ts/templates/formatters.ts +6 -4
  39. package/dist/templates/_shared/lib-ts/types.ts +68 -55
  40. package/dist/templates/_shared/scripts/resolve_context.ts +24 -0
  41. package/dist/templates/_shared/scripts/resume_handoff.ts +345 -0
  42. package/dist/templates/_shared/scripts/save_handoff.ts +3 -3
  43. package/dist/templates/_shared/scripts/status_line.ts +687 -0
  44. package/dist/templates/cc-native/.claude/settings.json +175 -185
  45. package/dist/templates/cc-native/TEMPLATE-SCHEMA.md +15 -17
  46. package/dist/templates/cc-native/_cc-native/agents/CLAUDE.md +0 -2
  47. package/dist/templates/cc-native/_cc-native/hooks/CLAUDE.md +109 -135
  48. package/dist/templates/cc-native/_cc-native/hooks/add_plan_context.ts +119 -0
  49. package/dist/templates/cc-native/_cc-native/hooks/cc-native-plan-review.ts +1027 -0
  50. package/dist/templates/cc-native/_cc-native/hooks/plan_questions_early.ts +61 -0
  51. package/dist/templates/cc-native/_cc-native/lib-ts/aggregate-agents.ts +156 -0
  52. package/dist/templates/cc-native/_cc-native/lib-ts/artifacts.ts +792 -0
  53. package/dist/templates/cc-native/_cc-native/lib-ts/cc-native-state.ts +199 -0
  54. package/dist/templates/cc-native/_cc-native/lib-ts/cli-output-parser.ts +144 -0
  55. package/dist/templates/cc-native/_cc-native/lib-ts/config.ts +57 -0
  56. package/dist/templates/cc-native/_cc-native/lib-ts/constants.ts +83 -0
  57. package/dist/templates/cc-native/_cc-native/lib-ts/corroboration.ts +115 -0
  58. package/dist/templates/cc-native/_cc-native/lib-ts/debug.ts +80 -0
  59. package/dist/templates/cc-native/_cc-native/lib-ts/index.ts +120 -0
  60. package/dist/templates/cc-native/_cc-native/lib-ts/json-parser.ts +168 -0
  61. package/dist/templates/cc-native/_cc-native/lib-ts/nul +3 -0
  62. package/dist/templates/cc-native/_cc-native/lib-ts/orchestrator.ts +250 -0
  63. package/dist/templates/cc-native/_cc-native/lib-ts/reviewers/agent.ts +275 -0
  64. package/dist/templates/cc-native/_cc-native/lib-ts/reviewers/codex.ts +130 -0
  65. package/dist/templates/cc-native/_cc-native/lib-ts/reviewers/gemini.ts +107 -0
  66. package/dist/templates/cc-native/_cc-native/lib-ts/reviewers/index.ts +10 -0
  67. package/dist/templates/cc-native/_cc-native/lib-ts/reviewers/types.ts +23 -0
  68. package/dist/templates/cc-native/_cc-native/lib-ts/state.ts +240 -0
  69. package/dist/templates/cc-native/_cc-native/lib-ts/tsconfig.json +18 -0
  70. package/dist/templates/cc-native/_cc-native/lib-ts/types.ts +385 -0
  71. package/dist/templates/cc-native/_cc-native/lib-ts/verdict.ts +72 -0
  72. package/dist/templates/cc-native/_cc-native/plan-review.config.json +14 -1
  73. package/oclif.manifest.json +1 -1
  74. package/package.json +2 -2
  75. package/dist/templates/_shared/hooks/__init__.py +0 -16
  76. package/dist/templates/_shared/hooks/__pycache__/__init__.cpython-313.pyc +0 -0
  77. package/dist/templates/_shared/hooks/__pycache__/archive_plan.cpython-313.pyc +0 -0
  78. package/dist/templates/_shared/hooks/__pycache__/context_enforcer.cpython-313.pyc +0 -0
  79. package/dist/templates/_shared/hooks/__pycache__/context_monitor.cpython-313.pyc +0 -0
  80. package/dist/templates/_shared/hooks/__pycache__/file-suggestion.cpython-313.pyc +0 -0
  81. package/dist/templates/_shared/hooks/__pycache__/pre_compact.cpython-313.pyc +0 -0
  82. package/dist/templates/_shared/hooks/__pycache__/session_end.cpython-313.pyc +0 -0
  83. package/dist/templates/_shared/hooks/__pycache__/session_start.cpython-313.pyc +0 -0
  84. package/dist/templates/_shared/hooks/__pycache__/task_create_atomicity.cpython-313.pyc +0 -0
  85. package/dist/templates/_shared/hooks/__pycache__/task_create_capture.cpython-313.pyc +0 -0
  86. package/dist/templates/_shared/hooks/__pycache__/task_update_capture.cpython-313.pyc +0 -0
  87. package/dist/templates/_shared/hooks/__pycache__/user_prompt_submit.cpython-313.pyc +0 -0
  88. package/dist/templates/_shared/hooks/archive_plan.py +0 -177
  89. package/dist/templates/_shared/hooks/context_monitor.py +0 -270
  90. package/dist/templates/_shared/hooks/file-suggestion.py +0 -215
  91. package/dist/templates/_shared/hooks/pre_compact.py +0 -104
  92. package/dist/templates/_shared/hooks/session_end.py +0 -173
  93. package/dist/templates/_shared/hooks/session_start.py +0 -206
  94. package/dist/templates/_shared/hooks/task_create_capture.py +0 -108
  95. package/dist/templates/_shared/hooks/task_update_capture.py +0 -145
  96. package/dist/templates/_shared/hooks/user_prompt_submit.py +0 -139
  97. package/dist/templates/_shared/lib/__init__.py +0 -1
  98. package/dist/templates/_shared/lib/__pycache__/__init__.cpython-313.pyc +0 -0
  99. package/dist/templates/_shared/lib/base/__init__.py +0 -65
  100. package/dist/templates/_shared/lib/base/__pycache__/__init__.cpython-313.pyc +0 -0
  101. package/dist/templates/_shared/lib/base/__pycache__/atomic_write.cpython-313.pyc +0 -0
  102. package/dist/templates/_shared/lib/base/__pycache__/constants.cpython-313.pyc +0 -0
  103. package/dist/templates/_shared/lib/base/__pycache__/hook_utils.cpython-313.pyc +0 -0
  104. package/dist/templates/_shared/lib/base/__pycache__/inference.cpython-313.pyc +0 -0
  105. package/dist/templates/_shared/lib/base/__pycache__/logger.cpython-313.pyc +0 -0
  106. package/dist/templates/_shared/lib/base/__pycache__/stop_words.cpython-313.pyc +0 -0
  107. package/dist/templates/_shared/lib/base/__pycache__/subprocess_utils.cpython-313.pyc +0 -0
  108. package/dist/templates/_shared/lib/base/__pycache__/utils.cpython-313.pyc +0 -0
  109. package/dist/templates/_shared/lib/base/atomic_write.py +0 -180
  110. package/dist/templates/_shared/lib/base/constants.py +0 -358
  111. package/dist/templates/_shared/lib/base/hook_utils.py +0 -339
  112. package/dist/templates/_shared/lib/base/inference.py +0 -307
  113. package/dist/templates/_shared/lib/base/logger.py +0 -305
  114. package/dist/templates/_shared/lib/base/stop_words.py +0 -221
  115. package/dist/templates/_shared/lib/base/subprocess_utils.py +0 -46
  116. package/dist/templates/_shared/lib/base/utils.py +0 -263
  117. package/dist/templates/_shared/lib/context/__init__.py +0 -102
  118. package/dist/templates/_shared/lib/context/__pycache__/__init__.cpython-313.pyc +0 -0
  119. package/dist/templates/_shared/lib/context/__pycache__/cache.cpython-313.pyc +0 -0
  120. package/dist/templates/_shared/lib/context/__pycache__/context_extractor.cpython-313.pyc +0 -0
  121. package/dist/templates/_shared/lib/context/__pycache__/context_formatter.cpython-313.pyc +0 -0
  122. package/dist/templates/_shared/lib/context/__pycache__/context_manager.cpython-313.pyc +0 -0
  123. package/dist/templates/_shared/lib/context/__pycache__/context_selector.cpython-313.pyc +0 -0
  124. package/dist/templates/_shared/lib/context/__pycache__/context_store.cpython-313.pyc +0 -0
  125. package/dist/templates/_shared/lib/context/__pycache__/discovery.cpython-313.pyc +0 -0
  126. package/dist/templates/_shared/lib/context/__pycache__/event_log.cpython-313.pyc +0 -0
  127. package/dist/templates/_shared/lib/context/__pycache__/plan_archive.cpython-313.pyc +0 -0
  128. package/dist/templates/_shared/lib/context/__pycache__/plan_manager.cpython-313.pyc +0 -0
  129. package/dist/templates/_shared/lib/context/__pycache__/task_sync.cpython-313.pyc +0 -0
  130. package/dist/templates/_shared/lib/context/__pycache__/task_tracker.cpython-313.pyc +0 -0
  131. package/dist/templates/_shared/lib/context/context_formatter.py +0 -317
  132. package/dist/templates/_shared/lib/context/context_selector.py +0 -508
  133. package/dist/templates/_shared/lib/context/context_store.py +0 -653
  134. package/dist/templates/_shared/lib/context/plan_manager.py +0 -303
  135. package/dist/templates/_shared/lib/context/task_tracker.py +0 -188
  136. package/dist/templates/_shared/lib/handoff/__init__.py +0 -22
  137. package/dist/templates/_shared/lib/handoff/__pycache__/__init__.cpython-313.pyc +0 -0
  138. package/dist/templates/_shared/lib/handoff/__pycache__/document_generator.cpython-313.pyc +0 -0
  139. package/dist/templates/_shared/lib/handoff/document_generator.py +0 -278
  140. package/dist/templates/_shared/lib/templates/README.md +0 -206
  141. package/dist/templates/_shared/lib/templates/__init__.py +0 -36
  142. package/dist/templates/_shared/lib/templates/__pycache__/__init__.cpython-313.pyc +0 -0
  143. package/dist/templates/_shared/lib/templates/__pycache__/formatters.cpython-313.pyc +0 -0
  144. package/dist/templates/_shared/lib/templates/__pycache__/persona_questions.cpython-313.pyc +0 -0
  145. package/dist/templates/_shared/lib/templates/__pycache__/plan_context.cpython-313.pyc +0 -0
  146. package/dist/templates/_shared/lib/templates/formatters.py +0 -146
  147. package/dist/templates/_shared/lib/templates/plan_context.py +0 -73
  148. package/dist/templates/_shared/scripts/__pycache__/save_handoff.cpython-313.pyc +0 -0
  149. package/dist/templates/_shared/scripts/__pycache__/status_line.cpython-313.pyc +0 -0
  150. package/dist/templates/_shared/scripts/save_handoff.py +0 -357
  151. package/dist/templates/_shared/scripts/status_line.py +0 -716
  152. package/dist/templates/cc-native/.claude/commands/cc-native/fresh-perspective.md +0 -8
  153. package/dist/templates/cc-native/.windsurf/workflows/cc-native/fresh-perspective.md +0 -8
  154. package/dist/templates/cc-native/MIGRATION.md +0 -86
  155. package/dist/templates/cc-native/_cc-native/hooks/__pycache__/add_plan_context.cpython-313.pyc +0 -0
  156. package/dist/templates/cc-native/_cc-native/hooks/__pycache__/cc-native-plan-review.cpython-313.pyc +0 -0
  157. package/dist/templates/cc-native/_cc-native/hooks/__pycache__/mark_questions_asked.cpython-313.pyc +0 -0
  158. package/dist/templates/cc-native/_cc-native/hooks/__pycache__/plan_accepted.cpython-313.pyc +0 -0
  159. package/dist/templates/cc-native/_cc-native/hooks/__pycache__/plan_questions_early.cpython-313.pyc +0 -0
  160. package/dist/templates/cc-native/_cc-native/hooks/__pycache__/suggest-fresh-perspective.cpython-313.pyc +0 -0
  161. package/dist/templates/cc-native/_cc-native/hooks/add_plan_context.py +0 -130
  162. package/dist/templates/cc-native/_cc-native/hooks/cc-native-plan-review.py +0 -954
  163. package/dist/templates/cc-native/_cc-native/hooks/plan_questions_early.py +0 -81
  164. package/dist/templates/cc-native/_cc-native/hooks/suggest-fresh-perspective.py +0 -340
  165. package/dist/templates/cc-native/_cc-native/lib/CLAUDE.md +0 -265
  166. package/dist/templates/cc-native/_cc-native/lib/__init__.py +0 -53
  167. package/dist/templates/cc-native/_cc-native/lib/__pycache__/__init__.cpython-313.pyc +0 -0
  168. package/dist/templates/cc-native/_cc-native/lib/__pycache__/atomic_write.cpython-313.pyc +0 -0
  169. package/dist/templates/cc-native/_cc-native/lib/__pycache__/constants.cpython-313.pyc +0 -0
  170. package/dist/templates/cc-native/_cc-native/lib/__pycache__/debug.cpython-313.pyc +0 -0
  171. package/dist/templates/cc-native/_cc-native/lib/__pycache__/orchestrator.cpython-313.pyc +0 -0
  172. package/dist/templates/cc-native/_cc-native/lib/__pycache__/state.cpython-313.pyc +0 -0
  173. package/dist/templates/cc-native/_cc-native/lib/__pycache__/utils.cpython-313.pyc +0 -0
  174. package/dist/templates/cc-native/_cc-native/lib/constants.py +0 -45
  175. package/dist/templates/cc-native/_cc-native/lib/debug.py +0 -139
  176. package/dist/templates/cc-native/_cc-native/lib/orchestrator.py +0 -362
  177. package/dist/templates/cc-native/_cc-native/lib/reviewers/__init__.py +0 -28
  178. package/dist/templates/cc-native/_cc-native/lib/reviewers/__pycache__/__init__.cpython-313.pyc +0 -0
  179. package/dist/templates/cc-native/_cc-native/lib/reviewers/__pycache__/agent.cpython-313.pyc +0 -0
  180. package/dist/templates/cc-native/_cc-native/lib/reviewers/__pycache__/base.cpython-313.pyc +0 -0
  181. package/dist/templates/cc-native/_cc-native/lib/reviewers/__pycache__/codex.cpython-313.pyc +0 -0
  182. package/dist/templates/cc-native/_cc-native/lib/reviewers/__pycache__/gemini.cpython-313.pyc +0 -0
  183. package/dist/templates/cc-native/_cc-native/lib/reviewers/agent.py +0 -215
  184. package/dist/templates/cc-native/_cc-native/lib/reviewers/base.py +0 -88
  185. package/dist/templates/cc-native/_cc-native/lib/reviewers/codex.py +0 -124
  186. package/dist/templates/cc-native/_cc-native/lib/reviewers/gemini.py +0 -108
  187. package/dist/templates/cc-native/_cc-native/lib/state.py +0 -268
  188. package/dist/templates/cc-native/_cc-native/lib/utils.py +0 -1071
  189. package/dist/templates/cc-native/_cc-native/scripts/__pycache__/aggregate_agents.cpython-313.pyc +0 -0
  190. package/dist/templates/cc-native/_cc-native/scripts/aggregate_agents.py +0 -168
  191. package/dist/templates/cc-native/_cc-native/workflows/fresh-perspective.md +0 -134
@@ -0,0 +1,1027 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * CC-Native Plan Review Hook (Unified)
4
+ *
5
+ * Claude Code PreToolUse hook that intercepts ExitPlanMode and
6
+ * automatically reviews plans using:
7
+ * 1. CLI reviewers (Codex + Gemini)
8
+ * 2. Plan orchestrator for complexity analysis
9
+ * 3. Claude Code agents in parallel
10
+ *
11
+ * Trigger: ExitPlanMode tool use (PreToolUse - runs BEFORE user approval prompt)
12
+ *
13
+ * Configuration: _cc-native/plan-review.config.json -> planReview, agentReview
14
+ *
15
+ * Output: _output/cc-native/plans/{YYYY-MM-DD}/{slug}/reviews/
16
+ * - review.json (combined review data)
17
+ * - review.md (combined markdown)
18
+ * - plan.md (plan snapshot at review time)
19
+ * - reviewer-output/{reviewer}.json (individual reviewer results)
20
+ */
21
+
22
+ import * as fs from "node:fs";
23
+ import * as path from "node:path";
24
+ import * as os from "node:os";
25
+ import * as crypto from "node:crypto";
26
+
27
+ import {
28
+ loadHookInput,
29
+ runHookAsync,
30
+ logDebug,
31
+ logInfo,
32
+ logWarn,
33
+ logError,
34
+ logDiagnostic,
35
+ emitContext,
36
+ emitContextAndBlock,
37
+ } from "../../_shared/lib-ts/base/hook-utils.js";
38
+ import { isInternalCall, findExecutable } from "../../_shared/lib-ts/base/subprocess-utils.js";
39
+ import { getProjectRoot, getAiwcliDir, getContextReviewsDir, getContextDir, getReviewFolderPath } from "../../_shared/lib-ts/base/constants.js";
40
+ import { eprint } from "../../_shared/lib-ts/base/utils.js";
41
+ import { getContextBySessionId, getAllContexts } from "../../_shared/lib-ts/context/context-store.js";
42
+ import { findPlanPathInTranscript } from "../../_shared/lib-ts/context/plan-manager.js";
43
+
44
+ import type {
45
+ AgentConfig,
46
+ OrchestratorConfig,
47
+ ProviderConfig,
48
+ ModelsConfig,
49
+ ReviewerResult,
50
+ CombinedReviewResult,
51
+ OrchestratorResult,
52
+ Verdict,
53
+ IterationState,
54
+ } from "../lib-ts/types.js";
55
+ import type { ContextState } from "../../_shared/lib-ts/types.js";
56
+ import {
57
+ REVIEW_SCHEMA,
58
+ DEFAULT_DISPLAY,
59
+ DEFAULT_SANITIZATION,
60
+ } from "../lib-ts/types.js";
61
+
62
+ import {
63
+ isPlanAlreadyReviewed,
64
+ wasPlanPreviouslyDenied,
65
+ markPlanReviewed,
66
+ } from "../lib-ts/cc-native-state.js";
67
+
68
+ import { worstVerdict } from "../lib-ts/verdict.js";
69
+ import { computeCorroboratedDecision } from "../lib-ts/corroboration.js";
70
+ import { loadConfig, getDisplaySettings } from "../lib-ts/config.js";
71
+ import { runOrchestrator } from "../lib-ts/orchestrator.js";
72
+ import { aggregateAgents } from "../lib-ts/aggregate-agents.js";
73
+ import { debugLog } from "../lib-ts/debug.js";
74
+ import {
75
+ writeCombinedArtifacts,
76
+ buildInlineReviewSummary,
77
+ extractTopIssuesText,
78
+ buildHighIssuesDocument,
79
+ writeReviewTracker,
80
+ } from "../lib-ts/artifacts.js";
81
+ import type { ReviewTrackerEntry } from "../lib-ts/artifacts.js";
82
+ import { runAgentReview, runCodexReview, runGeminiReview } from "../lib-ts/reviewers/index.js";
83
+ import { DEFAULT_REVIEW_ITERATIONS } from "../lib-ts/state.js";
84
+
85
+ // ---------------------------------------------------------------------------
86
+ // Hook Name
87
+ // ---------------------------------------------------------------------------
88
+
89
+ const HOOK = "cc-native-plan-review";
90
+
91
+ // ---------------------------------------------------------------------------
92
+ // Inline Utilities (no TS export for these yet)
93
+ // ---------------------------------------------------------------------------
94
+
95
+ function findPlanFile(): string | null {
96
+ const plansDir = path.join(os.homedir(), ".claude", "plans");
97
+ if (!fs.existsSync(plansDir)) return null;
98
+ const files = fs.readdirSync(plansDir)
99
+ .filter(f => f.endsWith(".md"))
100
+ .map(f => {
101
+ const p = path.join(plansDir, f);
102
+ return { path: p, mtime: fs.statSync(p).mtimeMs };
103
+ })
104
+ .sort((a, b) => b.mtime - a.mtime);
105
+ return files.length > 0 ? files[0]!.path : null;
106
+ }
107
+
108
+ function computePlanHash(content: string): string {
109
+ return crypto.createHash("sha256").update(content, "utf-8").digest("hex").slice(0, 16);
110
+ }
111
+
112
+ function skipWithInfo(reason: string): void {
113
+ logInfo(HOOK, `Skipping: ${reason}`);
114
+ emitContext(`[Plan Review Skipped] ${reason}`);
115
+ }
116
+
117
+ function extractTopIssuesForTracker(
118
+ combined: CombinedReviewResult,
119
+ maxCount = 5,
120
+ ): string[] {
121
+ const allReviewers = [
122
+ ...Object.values(combined.cli_reviewers),
123
+ ...Object.values(combined.agents),
124
+ ];
125
+ const issues: string[] = [];
126
+ for (const r of allReviewers) {
127
+ if (!r.data) continue;
128
+ const issueList = r.data.issues as Array<Record<string, unknown>> | undefined;
129
+ if (!issueList) continue;
130
+ for (const issue of issueList) {
131
+ if (issue.severity === "high") {
132
+ const text = String(issue.issue ?? "").trim();
133
+ if (text) {
134
+ issues.push(`[${r.name}] ${text}`);
135
+ }
136
+ }
137
+ }
138
+ if (issues.length >= maxCount) break;
139
+ }
140
+ return issues.slice(0, maxCount);
141
+ }
142
+
143
+ // ---------------------------------------------------------------------------
144
+ // Graduation Logic
145
+ // ---------------------------------------------------------------------------
146
+
147
+ /**
148
+ * Determine which agents are pass-eligible this iteration.
149
+ * Criteria: verdict === "pass" OR zero high-severity issues.
150
+ * Agents with "skip"/"error" are NOT eligible (no signal).
151
+ */
152
+ function computePassEligible(agentResults: Record<string, ReviewerResult>): string[] {
153
+ const eligible: string[] = [];
154
+ for (const [name, result] of Object.entries(agentResults)) {
155
+ if (result.verdict === "skip" || result.verdict === "error") continue;
156
+ if (result.verdict === "pass") { eligible.push(name); continue; }
157
+ const issues = Array.isArray(result.data?.issues)
158
+ ? (result.data.issues as Array<{ severity?: string }>) : [];
159
+ if (issues.filter(i => i.severity === "high").length === 0) {
160
+ eligible.push(name);
161
+ }
162
+ }
163
+ return eligible;
164
+ }
165
+
166
+ // ---------------------------------------------------------------------------
167
+ // Default Configuration
168
+ // ---------------------------------------------------------------------------
169
+
170
+ const ALL_CATEGORIES = ["code", "infrastructure", "documentation", "design", "research", "life", "business"];
171
+ const CODE_INFRA_DESIGN = ["code", "infrastructure", "design"];
172
+ const CODE_INFRA = ["code", "infrastructure"];
173
+ const AGENT_DEFAULTS = { model: "sonnet", provider: "claude", enabled: true } as const;
174
+
175
+ const DEFAULT_AGENTS: Array<{ name: string; model: string; provider: string; focus: string; enabled: boolean; categories: string[] }> = [
176
+ { ...AGENT_DEFAULTS, name: "handoff-readiness", focus: "fresh context execution readiness", categories: ALL_CATEGORIES },
177
+ { ...AGENT_DEFAULTS, name: "clarity-auditor", focus: "communication clarity and execution readiness", categories: ALL_CATEGORIES },
178
+ { ...AGENT_DEFAULTS, name: "skeptic", focus: "problem-solution alignment and assumption validation", categories: ALL_CATEGORIES },
179
+ { ...AGENT_DEFAULTS, name: "documentation-philosophy", focus: "knowledge capture and documentation placement", categories: ALL_CATEGORIES },
180
+ { ...AGENT_DEFAULTS, name: "risk-premortem", focus: "pre-mortem failure analysis", categories: ALL_CATEGORIES },
181
+ { ...AGENT_DEFAULTS, name: "risk-fmea", focus: "systematic failure mode analysis", categories: CODE_INFRA_DESIGN },
182
+ { ...AGENT_DEFAULTS, name: "risk-dependency", focus: "dependency chain and blast radius analysis", categories: CODE_INFRA },
183
+ { ...AGENT_DEFAULTS, name: "risk-reversibility", focus: "decision reversibility and optionality", categories: ALL_CATEGORIES },
184
+ { ...AGENT_DEFAULTS, name: "completeness-gaps", focus: "structural gap analysis", categories: ALL_CATEGORIES },
185
+ { ...AGENT_DEFAULTS, name: "completeness-feasibility", focus: "feasibility and resource analysis", categories: ALL_CATEGORIES },
186
+ { ...AGENT_DEFAULTS, name: "completeness-ordering", focus: "step ordering and critical path analysis", categories: CODE_INFRA_DESIGN },
187
+ { ...AGENT_DEFAULTS, name: "arch-structure", focus: "coupling, cohesion, and boundary analysis", categories: CODE_INFRA_DESIGN },
188
+ { ...AGENT_DEFAULTS, name: "arch-evolution", focus: "evolutionary architecture and change amplification", categories: CODE_INFRA_DESIGN },
189
+ { ...AGENT_DEFAULTS, name: "arch-patterns", focus: "pattern selection and technology fit", categories: CODE_INFRA },
190
+ { ...AGENT_DEFAULTS, name: "verify-coverage", focus: "verification coverage mapping", categories: ALL_CATEGORIES },
191
+ { ...AGENT_DEFAULTS, name: "verify-strength", focus: "test quality and mutation analysis", categories: CODE_INFRA },
192
+ { ...AGENT_DEFAULTS, name: "tradeoff-costs", focus: "opportunity cost and capability sacrifice", categories: ALL_CATEGORIES },
193
+ { ...AGENT_DEFAULTS, name: "tradeoff-stakeholders", focus: "stakeholder impact and cost-benefit asymmetry", categories: ALL_CATEGORIES },
194
+ { ...AGENT_DEFAULTS, name: "scope-boundary", focus: "scope drift and boundary enforcement", categories: ALL_CATEGORIES },
195
+ { ...AGENT_DEFAULTS, name: "hidden-complexity", focus: "understated complexity and hidden difficulty", categories: ALL_CATEGORIES },
196
+ { ...AGENT_DEFAULTS, name: "simplicity-guardian", focus: "over-engineering and unnecessary complexity", categories: ALL_CATEGORIES },
197
+ { ...AGENT_DEFAULTS, name: "devils-advocate", focus: "contrarian analysis and reductio ad absurdum", categories: ALL_CATEGORIES },
198
+ { ...AGENT_DEFAULTS, name: "assumption-tracer", focus: "dependency chains and foundational assumptions", categories: ALL_CATEGORIES },
199
+ { ...AGENT_DEFAULTS, name: "incremental-delivery", focus: "incremental delivery and vertical slicing", categories: ALL_CATEGORIES },
200
+ { ...AGENT_DEFAULTS, name: "constraint-validator", focus: "constraint identification and satisfaction", categories: ALL_CATEGORIES },
201
+ ];
202
+
203
+ const DEFAULT_ORCHESTRATOR: { enabled: boolean; model: string; timeout: number } = { enabled: true, model: "opus", timeout: 60 };
204
+ const DEFAULT_AGENT_MODEL = "sonnet";
205
+
206
+ const DEFAULT_AGENT_SELECTION: Record<string, unknown> = {
207
+ simple: { min: 3, max: 3 },
208
+ medium: { min: 5, max: 5 },
209
+ high: { min: 7, max: 7 },
210
+ fallbackCount: 3,
211
+ };
212
+
213
+ const DEFAULT_COMPLEXITY_CATEGORIES = ["code", "infrastructure", "documentation", "life", "business", "design", "research"];
214
+
215
+ // ---------------------------------------------------------------------------
216
+ // Mandatory Agent Resolution
217
+ // ---------------------------------------------------------------------------
218
+
219
+ function resolveMandatoryAgents(
220
+ configValue: unknown,
221
+ complexity: string,
222
+ ): Set<string> {
223
+ if (Array.isArray(configValue)) {
224
+ return new Set(configValue as string[]);
225
+ }
226
+ if (!configValue || typeof configValue !== "object") {
227
+ return new Set(["handoff-readiness", "clarity-auditor", "skeptic"]);
228
+ }
229
+ const cfg = configValue as Record<string, string[]>;
230
+ const names = new Set(cfg.always ?? []);
231
+ if (complexity === "medium" || complexity === "high") {
232
+ for (const n of cfg["medium+"] ?? []) names.add(n);
233
+ }
234
+ if (complexity === "high") {
235
+ for (const n of cfg.high ?? []) names.add(n);
236
+ }
237
+ return names;
238
+ }
239
+
240
+ // ---------------------------------------------------------------------------
241
+ // Context Lookup
242
+ // ---------------------------------------------------------------------------
243
+
244
+ function getActiveContextForReview(sessionId: string, projectRoot: string): ContextState | null {
245
+ // Strategy 1: By session_id
246
+ const ctx = getContextBySessionId(sessionId, projectRoot);
247
+ if (ctx) {
248
+ logInfo(HOOK, `Found context by session_id: ${ctx.id}`);
249
+ return ctx;
250
+ }
251
+ // Strategy 2: Single planning context
252
+ const allActive = getAllContexts("active", projectRoot);
253
+ const planning = allActive.filter(c => c.mode === "active" || c.mode === "has_plan");
254
+ if (planning.length === 1) {
255
+ logInfo(HOOK, `Found single planning context: ${planning[0]!.id}`);
256
+ return planning[0]!;
257
+ }
258
+ if (planning.length > 1) {
259
+ logWarn(HOOK, `Multiple planning contexts (${planning.length}), cannot determine which to use`);
260
+ } else if (allActive.length > 0) {
261
+ logInfo(HOOK, `Found ${allActive.length} active context(s) but none in planning mode`);
262
+ } else {
263
+ logInfo(HOOK, "No active contexts found");
264
+ }
265
+ return null;
266
+ }
267
+
268
+ // ---------------------------------------------------------------------------
269
+ // Iteration State
270
+ // ---------------------------------------------------------------------------
271
+
272
+ function loadIterationState(reviewsDir: string): IterationState | null {
273
+ const iterationFile = path.join(reviewsDir, "iteration.json");
274
+ if (!fs.existsSync(iterationFile)) return null;
275
+ try {
276
+ return JSON.parse(fs.readFileSync(iterationFile, "utf-8")) as IterationState;
277
+ } catch (e) {
278
+ logError(HOOK, `Failed to load iteration state: ${e}`);
279
+ return null;
280
+ }
281
+ }
282
+
283
+ function saveIterationState(reviewsDir: string, state: IterationState & { schema_version?: string }): boolean {
284
+ const iterationFile = path.join(reviewsDir, "iteration.json");
285
+ try {
286
+ fs.mkdirSync(reviewsDir, { recursive: true });
287
+ state.schema_version = "1.0.0";
288
+ fs.writeFileSync(iterationFile, JSON.stringify(state, null, 2), "utf-8");
289
+ return true;
290
+ } catch (e) {
291
+ logError(HOOK, `Failed to save iteration state: ${e}`);
292
+ return false;
293
+ }
294
+ }
295
+
296
+ // ---------------------------------------------------------------------------
297
+ // Model Provider Assignment
298
+ // ---------------------------------------------------------------------------
299
+
300
+ const DEFAULT_MODELS_CONFIG: ModelsConfig = {
301
+ providers: {
302
+ claude: { enabled: true, models: ["sonnet"] },
303
+ codex: { enabled: true, models: ["gpt-5.1-codex-mini"] },
304
+ },
305
+ };
306
+
307
+ function loadModelsConfig(settings: Record<string, unknown>): ModelsConfig {
308
+ const raw = settings.models as Record<string, unknown> | undefined;
309
+ if (!raw?.providers || typeof raw.providers !== "object") {
310
+ return DEFAULT_MODELS_CONFIG;
311
+ }
312
+ const providers: Record<string, ProviderConfig> = {};
313
+ for (const [name, cfg] of Object.entries(raw.providers as Record<string, unknown>)) {
314
+ const c = cfg as Record<string, unknown>;
315
+ providers[name] = {
316
+ enabled: c.enabled !== false,
317
+ models: Array.isArray(c.models) ? (c.models as string[]).filter(Boolean) : [],
318
+ };
319
+ }
320
+ return { providers };
321
+ }
322
+
323
+ function assignModelsToAgents(
324
+ agents: AgentConfig[],
325
+ modelsConfig: ModelsConfig,
326
+ ): AgentConfig[] {
327
+ // Filter to providers that are enabled, have models, AND whose CLI exists
328
+ const enabledProviders = Object.entries(modelsConfig.providers)
329
+ .filter(([name, config]) => {
330
+ if (!config.enabled || config.models.length === 0) return false;
331
+ const cliName = name === "claude" ? "claude" : name; // CLI name matches provider name
332
+ const found = findExecutable(cliName);
333
+ if (!found) {
334
+ logWarn(HOOK, `Provider '${name}' enabled but CLI '${cliName}' not found on PATH — skipping`);
335
+ }
336
+ return !!found;
337
+ });
338
+
339
+ if (enabledProviders.length === 0) {
340
+ logWarn(HOOK, "No providers with available CLI found, falling back to Claude with agent defaults");
341
+ return agents.map(a => ({ ...a, provider: "claude" }));
342
+ }
343
+
344
+ return agents.map(agent => {
345
+ const idx = Math.floor(Math.random() * enabledProviders.length);
346
+ const entry = enabledProviders[idx];
347
+ if (!entry) return { ...agent, provider: "claude" };
348
+ const [providerName, providerConfig] = entry;
349
+ const modelIdx = Math.floor(Math.random() * providerConfig.models.length);
350
+ const model = providerConfig.models[modelIdx] ?? providerConfig.models[0] ?? agent.model;
351
+ return { ...agent, provider: providerName, model };
352
+ });
353
+ }
354
+
355
+ // ---------------------------------------------------------------------------
356
+ // Settings Loading
357
+ // ---------------------------------------------------------------------------
358
+
359
+ function loadSettings(projDir: string): Record<string, unknown> {
360
+ const defaults: Record<string, unknown> = {
361
+ planReview: {
362
+ enabled: true,
363
+ reviewers: {
364
+ codex: { enabled: true, model: "", timeout: 120 },
365
+ gemini: { enabled: false, model: "", timeout: 120 },
366
+ },
367
+ display: { ...DEFAULT_DISPLAY },
368
+ },
369
+ agentReview: {
370
+ enabled: true,
371
+ orchestrator: { ...DEFAULT_ORCHESTRATOR },
372
+ timeout: 180,
373
+ highIssueThreshold: 3,
374
+ legacyMode: false,
375
+ display: { ...DEFAULT_DISPLAY },
376
+ agentSelection: { ...DEFAULT_AGENT_SELECTION },
377
+ agentDefaults: { model: DEFAULT_AGENT_MODEL },
378
+ complexityCategories: [...DEFAULT_COMPLEXITY_CATEGORIES],
379
+ sanitization: { ...DEFAULT_SANITIZATION },
380
+ },
381
+ };
382
+
383
+ const config = loadConfig(projDir);
384
+ if (!config || Object.keys(config).length === 0) return { ...defaults, models: {} };
385
+
386
+ // Merge planReview
387
+ const planReview = config.planReview ?? {};
388
+ const mergedPlan = { ...defaults.planReview, ...planReview };
389
+ if (planReview.reviewers) {
390
+ mergedPlan.reviewers = { ...defaults.planReview.reviewers, ...planReview.reviewers };
391
+ }
392
+ mergedPlan.display = getDisplaySettings(config, "planReview");
393
+
394
+ // Merge agentReview
395
+ const agentReview = (config as Record<string, unknown>).agentReview ?? {};
396
+ const mergedAgent = { ...defaults.agentReview, ...agentReview };
397
+ if (!mergedAgent.orchestrator || typeof mergedAgent.orchestrator !== "object") {
398
+ mergedAgent.orchestrator = { ...DEFAULT_ORCHESTRATOR };
399
+ } else {
400
+ mergedAgent.orchestrator = { ...DEFAULT_ORCHESTRATOR, ...mergedAgent.orchestrator };
401
+ }
402
+ mergedAgent.display = getDisplaySettings(config, "agentReview");
403
+ const configRecord = config as Record<string, unknown>;
404
+ mergedAgent.agentSelection = { ...DEFAULT_AGENT_SELECTION, ...((configRecord.agentSelection as Record<string, unknown>) ?? {}) };
405
+ mergedAgent.agentDefaults = { model: DEFAULT_AGENT_MODEL, ...((configRecord.agentDefaults as Record<string, unknown>) ?? {}) };
406
+ mergedAgent.complexityCategories = (configRecord.complexityCategories as string[]) ?? [...DEFAULT_COMPLEXITY_CATEGORIES];
407
+ mergedAgent.sanitization = { ...DEFAULT_SANITIZATION, ...((configRecord.sanitization as Record<string, unknown>) ?? {}) };
408
+ mergedAgent.reviewIterations = { ...DEFAULT_REVIEW_ITERATIONS, ...agentReview.reviewIterations ?? {} };
409
+
410
+ const modelsRaw = (config as Record<string, unknown>).models ?? {};
411
+ return { planReview: mergedPlan, agentReview: mergedAgent, models: modelsRaw };
412
+ }
413
+
414
+ function loadAgentLibrary(
415
+ projDir: string,
416
+ settings?: Record<string, unknown>,
417
+ ): AgentConfig[] {
418
+ const agentsData = aggregateAgents(path.join(projDir, "_cc-native", "agents"));
419
+ const defaultModel = settings?.agentDefaults?.model ?? DEFAULT_AGENT_MODEL;
420
+
421
+ if (!agentsData || agentsData.length === 0) {
422
+ logInfo(HOOK, "No agents found in frontmatter, using defaults");
423
+ return DEFAULT_AGENTS.map(a => ({
424
+ name: a.name,
425
+ model: a.model ?? defaultModel,
426
+ provider: a.provider ?? "claude",
427
+ focus: a.focus ?? "general review",
428
+ enabled: a.enabled ?? true,
429
+ categories: a.categories ?? ["code"],
430
+ description: "",
431
+ system_prompt: "",
432
+ }));
433
+ }
434
+
435
+ return agentsData.filter(a => a.name !== "plan-orchestrator");
436
+ }
437
+
438
+ // ---------------------------------------------------------------------------
439
+ // Main Hook
440
+ // ---------------------------------------------------------------------------
441
+
442
+ async function main(): Promise<void> {
443
+ logInfo(HOOK, "Unified hook started (PreToolUse)");
444
+
445
+ if (isInternalCall()) {
446
+ logDebug(HOOK, "Skipping: internal subprocess call");
447
+ return;
448
+ }
449
+
450
+ const payload = loadHookInput();
451
+ if (!payload) {
452
+ skipWithInfo("Invalid JSON input from Claude Code");
453
+ return;
454
+ }
455
+
456
+ const toolName = payload.tool_name;
457
+ logDebug(HOOK, `tool_name: ${toolName}`);
458
+
459
+ if (toolName !== "ExitPlanMode") {
460
+ logDebug(HOOK, "Skipping: not ExitPlanMode");
461
+ return;
462
+ }
463
+
464
+ const sessionId = String(payload.session_id ?? "unknown");
465
+ const base = getProjectRoot(payload.cwd);
466
+ const aiwcliDir = getAiwcliDir(base);
467
+ const settings = loadSettings(aiwcliDir);
468
+
469
+ const planSettings = settings.planReview ?? {};
470
+ const agentSettings = settings.agentReview ?? {};
471
+
472
+ const planReviewEnabled = planSettings.enabled ?? true;
473
+ const agentReviewEnabled = agentSettings.enabled ?? true;
474
+
475
+ if (!planReviewEnabled && !agentReviewEnabled) {
476
+ logInfo(HOOK, "Skipping: both plan and agent review disabled");
477
+ return;
478
+ }
479
+
480
+ // Find plan file: prefer transcript-based discovery (session-accurate), fall back to mtime scan
481
+ const transcriptPath = payload.transcript_path as string | undefined;
482
+ let planPath: string | null = null;
483
+
484
+ if (transcriptPath) {
485
+ planPath = findPlanPathInTranscript(transcriptPath);
486
+ if (planPath) {
487
+ logInfo(HOOK, `Found plan via transcript: ${planPath}`);
488
+ } else {
489
+ logDebug(HOOK, "No plan Write found in transcript, falling back to mtime scan");
490
+ }
491
+ }
492
+
493
+ if (!planPath) {
494
+ planPath = findPlanFile();
495
+ }
496
+
497
+ if (!planPath) {
498
+ skipWithInfo("No plan file found in ~/.claude/plans/. The plan may not have been written yet.");
499
+ return;
500
+ }
501
+
502
+ let plan: string;
503
+ try {
504
+ plan = fs.readFileSync(planPath, "utf-8").trim();
505
+ } catch (e) {
506
+ skipWithInfo(`Failed to read plan file: ${e}`);
507
+ return;
508
+ }
509
+
510
+ if (!plan) {
511
+ skipWithInfo("Plan file exists but is empty.");
512
+ return;
513
+ }
514
+
515
+ logInfo(HOOK, `Found plan at: ${planPath}`);
516
+ logDebug(HOOK, `Plan length: ${plan.length} chars`);
517
+
518
+ const planHash = computePlanHash(plan);
519
+ logDiagnostic(HOOK, "receive", `plan_size=${plan.length}, session=${sessionId.slice(0, 8)}`, {
520
+ inputs: { plan_hash: planHash, plan_size: plan.length, session_id: sessionId.slice(0, 12) },
521
+ });
522
+
523
+ // Find active context
524
+ const activeContext = getActiveContextForReview(sessionId, base);
525
+ if (!activeContext) {
526
+ skipWithInfo("No active planning context found for this session.");
527
+ return;
528
+ }
529
+
530
+ const contextId = activeContext.id;
531
+ const reviewsDir = path.join(getContextReviewsDir(contextId, base), "cc-native");
532
+ logDebug(HOOK, `Using context reviews dir: ${reviewsDir}`);
533
+
534
+ const contextPath = getContextDir(contextId, base);
535
+ logDebug(HOOK, `Context path for debug: ${contextPath}`);
536
+
537
+ // Plan-hash deduplication
538
+ logDebug(HOOK, `Plan hash: ${planHash}`);
539
+ if (isPlanAlreadyReviewed(sessionId, planHash, base)) {
540
+ if (wasPlanPreviouslyDenied(sessionId, planHash, base)) {
541
+ emitContextAndBlock(
542
+ "[Plan Review] Plan content unchanged since last review which found issues.",
543
+ "Plan unchanged since denial. Modify the plan to address review findings, then attempt ExitPlanMode again.",
544
+ );
545
+ return;
546
+ } else {
547
+ skipWithInfo("Plan already reviewed and approved (same hash).");
548
+ return;
549
+ }
550
+ }
551
+
552
+ // Single load of iteration state — reused throughout, saved once at end.
553
+ // Default max=1 is safe: first iteration 1>1=false (runs), Edit E updates max from config before save.
554
+ let iterationState: IterationState = loadIterationState(reviewsDir) ?? {
555
+ current: 1, max: 1, complexity: "medium",
556
+ history: [], graduated: [], passStreaks: {}, lastPlanHash: "",
557
+ };
558
+
559
+ // Reset iteration counter when plan content changes (BEFORE early exit check)
560
+ // Graduation state (graduated[], passStreaks{}) persists across plan changes.
561
+ const lastHash = iterationState.lastPlanHash ?? "";
562
+ if (lastHash && lastHash !== planHash) {
563
+ logInfo(HOOK, `Plan hash changed (${lastHash.slice(0, 8)}→${planHash.slice(0, 8)}), resetting iteration counter`);
564
+ iterationState.current = 1;
565
+ }
566
+
567
+ // Early iteration check: if we've exhausted max iterations, allow plan through
568
+ if (iterationState.current > iterationState.max) {
569
+ skipWithInfo(`Max review iterations reached (${iterationState.current - 1}/${iterationState.max}), allowing plan through.`);
570
+ return;
571
+ }
572
+
573
+ // Initialize result containers
574
+ const cliResults: Record<string, ReviewerResult> = {};
575
+ let orchResult: OrchestratorResult | null = null;
576
+ const agentResults: Record<string, ReviewerResult> = {};
577
+ let allVerdicts: Verdict[] = [];
578
+ let detectedComplexity = "medium";
579
+
580
+ // ============================================
581
+ // PHASE 1 & 2: CLI Reviewers + Orchestrator (PARALLEL)
582
+ // ============================================
583
+ const reviewersConfig = planReviewEnabled ? (planSettings.reviewers ?? {}) : {};
584
+ // Deprecated: agents now support Codex provider via models.providers.codex
585
+ const codexEnabled = planReviewEnabled && (reviewersConfig.codex?.enabled ?? false);
586
+ const geminiEnabled = planReviewEnabled && (reviewersConfig.gemini?.enabled ?? false);
587
+
588
+ // Graduated agents from previous iterations (empty after hash reset or on iteration 1)
589
+ const graduatedSet = new Set(iterationState.graduated);
590
+ if (graduatedSet.size > 0) {
591
+ logInfo(HOOK, `Graduated agents from previous iterations: ${[...graduatedSet].sort().join(", ")}`);
592
+ }
593
+
594
+ const agentLibrary = agentReviewEnabled ? loadAgentLibrary(aiwcliDir, agentSettings) : [];
595
+ const originalAgentCount = agentLibrary.length;
596
+ const enabledAgents = agentLibrary.filter(a => !graduatedSet.has(a.name));
597
+ const timeout = typeof agentSettings.timeout === "number" ? agentSettings.timeout : 120;
598
+ const legacyMode = agentSettings.legacyMode === true;
599
+
600
+ const orchSettings = agentSettings.orchestrator ?? DEFAULT_ORCHESTRATOR;
601
+ const orchestratorConfig: OrchestratorConfig = {
602
+ enabled: (orchSettings.enabled ?? true) && agentReviewEnabled,
603
+ model: orchSettings.model ?? "haiku",
604
+ timeout: orchSettings.timeout ?? 30,
605
+ };
606
+
607
+ const mandatoryConfig = agentSettings.mandatoryAgents ?? ["handoff-readiness", "clarity-auditor", "skeptic"];
608
+ const alwaysMandatory = resolveMandatoryAgents(mandatoryConfig, "simple");
609
+ let mandatoryNames = alwaysMandatory;
610
+
611
+ logDebug(HOOK, `Codex enabled: ${codexEnabled}, Gemini enabled: ${geminiEnabled}`);
612
+ logDebug(HOOK, `Agent library: ${agentLibrary.map(a => a.name)}`);
613
+ logDebug(HOOK, `Mandatory agents: ${[...mandatoryNames].sort()}`);
614
+ logDebug(HOOK, `Orchestrator enabled: ${orchestratorConfig.enabled}`);
615
+
616
+ // Build phase 1 tasks as promises
617
+ const phase1Promises: Array<{ name: string; promise: Promise<ReviewerResult | OrchestratorResult> }> = [];
618
+
619
+ if (codexEnabled) {
620
+ phase1Promises.push({
621
+ name: "codex",
622
+ promise: runCodexReview(plan, REVIEW_SCHEMA, planSettings),
623
+ });
624
+ }
625
+ if (geminiEnabled) {
626
+ phase1Promises.push({
627
+ name: "gemini",
628
+ promise: runGeminiReview(plan, REVIEW_SCHEMA, planSettings),
629
+ });
630
+ }
631
+ if (orchestratorConfig.enabled && enabledAgents.length > 0 && !legacyMode) {
632
+ phase1Promises.push({
633
+ name: "orchestrator",
634
+ promise: runOrchestrator(plan, enabledAgents, orchestratorConfig, agentSettings, alwaysMandatory),
635
+ });
636
+ }
637
+
638
+ logInfo(HOOK, `=== PHASE 1: Running ${phase1Promises.length} tasks in parallel ===`);
639
+
640
+ const phase1Results: Record<string, ReviewerResult | OrchestratorResult> = {};
641
+ if (phase1Promises.length > 0) {
642
+ const results = await Promise.allSettled(
643
+ phase1Promises.map(async ({ name, promise }) => {
644
+ const result = await promise;
645
+ return { name, result };
646
+ }),
647
+ );
648
+ for (const [i, r] of results.entries()) {
649
+ if (r.status === "fulfilled") {
650
+ phase1Results[r.value.name] = r.value.result;
651
+ logInfo(HOOK, `${r.value.name} completed`);
652
+ } else {
653
+ const failedName = phase1Promises[i]?.name ?? "unknown";
654
+ logError(HOOK, `${failedName} failed: ${r.reason}`);
655
+ }
656
+ }
657
+ }
658
+
659
+ // Collect CLI results
660
+ if (phase1Results.codex) cliResults.codex = phase1Results.codex as ReviewerResult;
661
+ if (phase1Results.gemini) cliResults.gemini = phase1Results.gemini as ReviewerResult;
662
+ if (phase1Results.orchestrator) orchResult = phase1Results.orchestrator as OrchestratorResult;
663
+
664
+ // ============================================
665
+ // PHASE 2: Agent Selection
666
+ // ============================================
667
+ if (agentReviewEnabled) {
668
+ logInfo(HOOK, "=== PHASE 2: Agent Selection ===");
669
+
670
+ let selectedAgents: AgentConfig[] = [];
671
+ const fallbackByComplexity = agentSettings.fallbackByComplexity ?? { simple: 0, medium: 2, high: 4 };
672
+
673
+ if (enabledAgents.length > 0) {
674
+ let mandatoryAgents = enabledAgents.filter(a => mandatoryNames.has(a.name));
675
+ let nonMandatory = enabledAgents.filter(a => !mandatoryNames.has(a.name));
676
+
677
+ logDebug(HOOK, `Mandatory agents: ${mandatoryAgents.map(a => a.name)}`);
678
+ logDebug(HOOK, `Non-mandatory pool: ${nonMandatory.length} agents`);
679
+
680
+ if (orchResult && !legacyMode) {
681
+ detectedComplexity = orchResult.complexity;
682
+
683
+ // Phase 2: Recompute mandatory with actual complexity
684
+ mandatoryNames = resolveMandatoryAgents(mandatoryConfig, detectedComplexity);
685
+ mandatoryAgents = enabledAgents.filter(a => mandatoryNames.has(a.name));
686
+ nonMandatory = enabledAgents.filter(a => !mandatoryNames.has(a.name));
687
+
688
+ const orchSelectedNames = new Set(
689
+ orchResult.selected_agents.filter(n => !mandatoryNames.has(n)),
690
+ );
691
+ let orchSelected = nonMandatory.filter(a => orchSelectedNames.has(a.name));
692
+
693
+ logDebug(HOOK, `Orchestrator selected (non-mandatory): ${orchSelected.map(a => a.name)}`);
694
+
695
+ // Warn if orchestrator returned unknown names
696
+ const knownNames = new Set(nonMandatory.map(a => a.name));
697
+ const unmatched = [...orchSelectedNames].filter(n => !knownNames.has(n));
698
+ if (unmatched.length > 0) {
699
+ logWarn(HOOK, `Orchestrator selected unknown agents: ${unmatched}`);
700
+ }
701
+
702
+ // Enforce minimum agent count
703
+ const minAdditional = fallbackByComplexity[detectedComplexity] ?? 5;
704
+ if (orchSelected.length < minAdditional && nonMandatory.length > 0) {
705
+ const remaining = nonMandatory.filter(a => !orchSelected.includes(a));
706
+ const topUpCount = Math.min(minAdditional - orchSelected.length, remaining.length);
707
+ if (topUpCount > 0) {
708
+ // Shuffle and take random sample
709
+ const shuffled = [...remaining].sort(() => Math.random() - 0.5);
710
+ const topUp = shuffled.slice(0, topUpCount);
711
+ orchSelected = [...orchSelected, ...topUp];
712
+ logDebug(HOOK, `Topped up ${topUpCount} agents to meet ${detectedComplexity} minimum: ${topUp.map(a => a.name)}`);
713
+ }
714
+ }
715
+
716
+ selectedAgents = [...mandatoryAgents, ...orchSelected];
717
+ logInfo(HOOK, `Final selection: ${selectedAgents.length} agents (${mandatoryAgents.length} mandatory + ${orchSelected.length} additional)`);
718
+ } else {
719
+ logInfo(HOOK, "Running in legacy mode (all enabled agents)");
720
+ detectedComplexity = "medium";
721
+ mandatoryNames = resolveMandatoryAgents(mandatoryConfig, detectedComplexity);
722
+ selectedAgents = enabledAgents;
723
+ }
724
+ }
725
+
726
+ logDiagnostic(HOOK, "decide", `Selected ${selectedAgents.length} agents, complexity=${detectedComplexity}`, {
727
+ decision: "agents_selected",
728
+ reasoning: `orchestrator=${orchResult !== null}, legacy=${legacyMode}`,
729
+ inputs: {
730
+ agents: selectedAgents.map(a => a.name),
731
+ complexity: detectedComplexity,
732
+ mandatory_count: selectedAgents.filter(a => mandatoryNames.has(a.name)).length,
733
+ },
734
+ });
735
+
736
+ // Update complexity/max on the already-loaded iteration state (no second disk read)
737
+ const reviewIterations: Record<string, number> = {
738
+ ...DEFAULT_REVIEW_ITERATIONS,
739
+ ...(agentSettings.reviewIterations ?? {}),
740
+ };
741
+ iterationState.complexity = detectedComplexity;
742
+ iterationState.max = reviewIterations[detectedComplexity] ?? iterationState.max;
743
+ logDebug(HOOK, `Iteration state: ${iterationState.current}/${iterationState.max} (${detectedComplexity})`);
744
+
745
+ // Assign random providers + models to selected agents
746
+ const modelsConfig = loadModelsConfig(settings);
747
+ selectedAgents = assignModelsToAgents(selectedAgents, modelsConfig);
748
+ logInfo(HOOK, `Model assignments: ${selectedAgents.map(a => `${a.name}→${a.provider}:${a.model}`).join(", ")}`);
749
+
750
+ // PHASE 3: Run selected agents in parallel
751
+ if (selectedAgents.length > 0) {
752
+ logInfo(HOOK, "=== PHASE 3: Agent Reviews ===");
753
+ logInfo(HOOK, `Launching ${selectedAgents.length} agents in parallel`);
754
+
755
+ debugLog(contextPath, sessionId, "hook", "agent_review_start", {
756
+ agents: selectedAgents.map(a => a.name),
757
+ timeout,
758
+ complexity: detectedComplexity,
759
+ });
760
+
761
+ const agentPromises = selectedAgents.map(async agent => {
762
+ const result = await runAgentReview(plan, agent, REVIEW_SCHEMA, timeout, contextPath, sessionId);
763
+ return { agent, result };
764
+ });
765
+
766
+ const agentSettled = await Promise.allSettled(agentPromises);
767
+ for (const [i, r] of agentSettled.entries()) {
768
+ if (r.status === "fulfilled") {
769
+ const { agent, result } = r.value;
770
+ agentResults[agent.name] = result;
771
+ logInfo(HOOK, `${agent.name} completed with verdict: ${result.verdict}`);
772
+ } else {
773
+ const failedAgent = selectedAgents[i]!;
774
+ logError(HOOK, `${failedAgent.name} failed with exception: ${r.reason}`);
775
+ agentResults[failedAgent.name] = {
776
+ name: failedAgent.name,
777
+ ok: false,
778
+ verdict: "error",
779
+ data: {},
780
+ raw: "",
781
+ err: String(r.reason),
782
+ };
783
+ }
784
+ }
785
+ }
786
+ }
787
+
788
+ // ============================================
789
+ // Enforce per-agent issue limit (truncate to top N by severity)
790
+ // ============================================
791
+ const maxIssuesPerAgent = typeof agentSettings.maxIssuesPerAgent === "number"
792
+ ? agentSettings.maxIssuesPerAgent : 3;
793
+
794
+ for (const r of [...Object.values(cliResults), ...Object.values(agentResults)]) {
795
+ if (!Array.isArray(r.data?.issues)) continue;
796
+ const issues = r.data.issues as Array<{ severity?: string }>;
797
+ if (issues.length <= maxIssuesPerAgent) continue;
798
+ const severityOrder: Record<string, number> = { high: 0, medium: 1, low: 2 };
799
+ issues.sort((a, b) => (severityOrder[a.severity ?? "low"] ?? 2) - (severityOrder[b.severity ?? "low"] ?? 2));
800
+ const originalCount = issues.length;
801
+ r.data.issues = issues.slice(0, maxIssuesPerAgent);
802
+ logInfo(HOOK, `${r.name}: truncated issues ${originalCount} → ${maxIssuesPerAgent}`);
803
+ }
804
+
805
+ // ============================================
806
+ // Compute pass-eligible agents (before verdict overrides)
807
+ // ============================================
808
+ const passEligible = computePassEligible(agentResults);
809
+ if (passEligible.length > 0) {
810
+ logInfo(HOOK, `Pass-eligible agents this iteration: ${passEligible.join(", ")}`);
811
+ }
812
+
813
+ // ============================================
814
+ // Per-agent high-severity threshold: override verdict to "fail"
815
+ // ============================================
816
+ const highIssueThreshold = typeof agentSettings.highIssueThreshold === "number" ? agentSettings.highIssueThreshold : 3;
817
+ allVerdicts = [];
818
+
819
+ for (const r of [...Object.values(cliResults), ...Object.values(agentResults)]) {
820
+ if (!r.verdict || r.verdict === "skip" || r.verdict === "error") continue;
821
+ const issues = Array.isArray(r.data?.issues) ? r.data.issues as Array<{ severity?: string }> : [];
822
+ const agentHigh = issues.filter(i => i.severity === "high").length;
823
+ let verdict = r.verdict;
824
+ if (agentHigh >= highIssueThreshold) {
825
+ logInfo(HOOK, `${r.name}: verdict overridden to 'fail' (${agentHigh} high issues >= ${highIssueThreshold})`);
826
+ verdict = "fail";
827
+ r.verdict = verdict;
828
+ }
829
+ allVerdicts.push(verdict);
830
+ }
831
+
832
+ // ============================================
833
+ // PHASE 4: Generate Combined Output
834
+ // ============================================
835
+ logInfo(HOOK, "=== PHASE 4: Generate Output ===");
836
+
837
+ if (Object.keys(cliResults).length === 0 && Object.keys(agentResults).length === 0) {
838
+ if (graduatedSet.size > 0 && originalAgentCount > 0) {
839
+ skipWithInfo("All agent reviewers graduated from previous iterations — no review needed.");
840
+ } else {
841
+ skipWithInfo("All reviewers failed to produce results. Check stderr logs for details.");
842
+ }
843
+ return;
844
+ }
845
+
846
+ const overall = allVerdicts.length > 0 ? worstVerdict(allVerdicts) : "pass";
847
+
848
+ const combinedResult: CombinedReviewResult = {
849
+ plan_hash: planHash,
850
+ overall_verdict: overall,
851
+ cli_reviewers: cliResults,
852
+ orchestration: orchResult,
853
+ agents: agentResults,
854
+ timestamp: new Date().toISOString(),
855
+ };
856
+
857
+ const displaySettings = {
858
+ ...(planSettings.display ?? {}),
859
+ ...(agentSettings.display ?? {}),
860
+ };
861
+ const combinedSettings = { display: displaySettings };
862
+
863
+ // Get current iteration number
864
+ const currentIteration = iterationState.current;
865
+
866
+ // Create review folder
867
+ const reviewFolder = getReviewFolderPath(contextId, currentIteration, base);
868
+ fs.mkdirSync(reviewFolder, { recursive: true });
869
+ logInfo(HOOK, `Created review folder: ${reviewFolder}`);
870
+
871
+ // Review decision — corroboration-based (proportional threshold per dimension)
872
+ // Must be computed before writeCombinedArtifacts and buildInlineReviewSummary which consume it.
873
+ const allReviewerResults: Record<string, ReviewerResult> = { ...cliResults, ...agentResults };
874
+ const corroborationResult = computeCorroboratedDecision(allReviewerResults);
875
+
876
+ const reviewFile = writeCombinedArtifacts(
877
+ base,
878
+ plan,
879
+ combinedResult,
880
+ payload as Record<string, unknown>,
881
+ combinedSettings,
882
+ undefined,
883
+ reviewFolder,
884
+ currentIteration,
885
+ corroborationResult,
886
+ );
887
+ logInfo(HOOK, `Saved review: ${reviewFile}`);
888
+
889
+ // Save plan snapshot for diffing between iterations
890
+ try {
891
+ fs.writeFileSync(path.join(reviewFolder, "plan.md"), plan, "utf-8");
892
+ logDebug(HOOK, `Saved plan snapshot: ${path.join(reviewFolder, "plan.md")}`);
893
+ } catch (e) {
894
+ logWarn(HOOK, `Failed to save plan snapshot: ${e}`);
895
+ }
896
+
897
+ // Build inline summary with top issues (always emitted, even on pass)
898
+ const inlineSummary = buildInlineReviewSummary(combinedResult, 5, 800, corroborationResult);
899
+ const topIssuesList = extractTopIssuesForTracker(combinedResult, 5);
900
+ const contextParts = [inlineSummary];
901
+ if (topIssuesList.length > 0) {
902
+ contextParts.push(`\nTop high-severity issues:\n${topIssuesList.map(i => `- ${i}`).join("\n")}`);
903
+ }
904
+ contextParts.push(`\nFull review: \`${reviewFile}\`\n`);
905
+ const shouldDeny = corroborationResult.blocking.length > 0;
906
+ const denyReason = shouldDeny ? "corroborated_issues" : "no_corroboration";
907
+ const reviewScore = shouldDeny ? 1.0 : 0.0;
908
+
909
+ logInfo(HOOK, `REVIEW_DECISION: verdict=${combinedResult.overall_verdict}, deny=${shouldDeny}, reason=${denyReason}, score=${reviewScore.toFixed(2)}`);
910
+ logDiagnostic(HOOK, "result", `verdict=${combinedResult.overall_verdict}, deny=${shouldDeny}, reason=${denyReason}`, {
911
+ decision: shouldDeny ? "deny" : "allow",
912
+ reasoning: `reason=${denyReason}, score=${reviewScore.toFixed(2)}`,
913
+ inputs: {
914
+ overall_verdict: combinedResult.overall_verdict,
915
+ review_score: Math.round(reviewScore * 100) / 100,
916
+ cli_count: Object.keys(cliResults).length,
917
+ agent_count: Object.keys(agentResults).length,
918
+ },
919
+ });
920
+
921
+ // Terminal progress
922
+ const verdictEmoji = shouldDeny ? "❌" : "✅";
923
+ eprint(`[plan-review] ${verdictEmoji} ${combinedResult.overall_verdict.toUpperCase()} (score=${reviewScore.toFixed(2)})`);
924
+ if (shouldDeny) {
925
+ eprint(`[plan-review] Blocking ExitPlanMode — ${denyReason}`);
926
+ }
927
+
928
+ // Iteration logic:
929
+ // - On PASS/WARRANT: set current past max so no more reviews happen
930
+ // - On DENY (fail/warn): increment current toward max (safety valve)
931
+ // - Max iterations (high=5, medium=3, simple=1) caps total reviews before auto-allow
932
+ if (reviewsDir) {
933
+ iterationState.history.push({ hash: planHash, verdict: overall, timestamp: new Date().toISOString() });
934
+ iterationState.lastPlanHash = planHash;
935
+
936
+ if (!shouldDeny) {
937
+ // Pass/warrant: stop iterating — set current past max
938
+ iterationState.current = iterationState.max + 1;
939
+ logInfo(HOOK, `Pass/warrant: stopping iterations`);
940
+ } else {
941
+ // Deny: advance iteration counter toward max so safety valve triggers
942
+ iterationState.current += 1;
943
+ logInfo(HOOK, `Deny: advancing iteration (${iterationState.current}/${iterationState.max})`);
944
+ }
945
+
946
+ // Update pass streaks — only for agents that actually ran this iteration
947
+ const passStreaks = { ...(iterationState.passStreaks ?? {}) };
948
+ const passEligibleSet = new Set(passEligible);
949
+ const graduatedSetCurrent = new Set(iterationState.graduated);
950
+
951
+ for (const name of Object.keys(agentResults)) {
952
+ if (graduatedSetCurrent.has(name)) continue;
953
+ if (passEligibleSet.has(name)) {
954
+ passStreaks[name] = (passStreaks[name] ?? 0) + 1;
955
+ } else {
956
+ passStreaks[name] = 0;
957
+ }
958
+ }
959
+ iterationState.passStreaks = passStreaks;
960
+
961
+ // Graduate agents that reached threshold
962
+ const GRADUATION_THRESHOLD = 2;
963
+ const newGrads: string[] = [];
964
+ for (const [name, streak] of Object.entries(passStreaks)) {
965
+ if (streak >= GRADUATION_THRESHOLD && !graduatedSetCurrent.has(name)) {
966
+ newGrads.push(name);
967
+ }
968
+ }
969
+ if (newGrads.length > 0) {
970
+ iterationState.graduated = [...iterationState.graduated, ...newGrads];
971
+ logInfo(HOOK, `Newly graduated (${GRADUATION_THRESHOLD} consecutive passes): ${newGrads.join(", ")}`);
972
+ }
973
+
974
+ saveIterationState(reviewsDir, iterationState);
975
+ }
976
+
977
+ // Write review tracker (human-readable lifecycle summary)
978
+ const ccNativeReviewsDir = path.dirname(reviewFolder);
979
+ const trackerDecision = shouldDeny ? "blocked" : "allow";
980
+ const trackerEntry: ReviewTrackerEntry = {
981
+ iteration: currentIteration,
982
+ timestamp: new Date().toISOString().replace("T", " ").slice(0, 16),
983
+ planHash,
984
+ verdict: combinedResult.overall_verdict,
985
+ decision: trackerDecision,
986
+ score: reviewScore,
987
+ topIssues: topIssuesList,
988
+ reviewFolder,
989
+ };
990
+ writeReviewTracker(ccNativeReviewsDir, trackerEntry);
991
+ logInfo(HOOK, `Updated review tracker: ${path.join(ccNativeReviewsDir, "review-tracker.md")}`);
992
+
993
+ // Emit output — always emit context with top issues + link; block only on fail
994
+ const contextText = contextParts.join("");
995
+
996
+ logDebug(HOOK, `REVIEW_CONTEXT_INJECTED: chars=${contextText.length}, inline_chars=${inlineSummary.length}`);
997
+
998
+ const REVIEWER_CAVEAT = "Reviewers have limited context compared to your full session — use your judgment to adopt valid points and dismiss genuine false positives. However, treat false positives as a clarity signal: if a reviewer misunderstood your plan, an agent executing it will likely hit the same confusion. Revise those sections to be unambiguous so no future reader — human or AI — makes the same mistake.";
999
+ const RESUBMIT_INSTRUCTION = "IMPORTANT: After revising the plan file, you MUST call ExitPlanMode again to trigger re-review. Do not end your turn or ask the user without calling ExitPlanMode.";
1000
+
1001
+ if (shouldDeny) {
1002
+ const disposition = `hook_deny_iter_${iterationState.current - 1}`;
1003
+ markPlanReviewed(sessionId, planHash, base, HOOK, iterationState, disposition);
1004
+ const topIssuesText = extractTopIssuesText(combinedResult, 3, "high");
1005
+ const highIssuesDoc = buildHighIssuesDocument(combinedResult, corroborationResult);
1006
+ const highIssuesPath = path.join(reviewFolder, "high-issues.md");
1007
+ fs.writeFileSync(highIssuesPath, highIssuesDoc, "utf-8");
1008
+
1009
+ const iterInfo = ` (iteration ${iterationState.current - 1}/${iterationState.max}, score=${reviewScore.toFixed(2)})`;
1010
+
1011
+ emitContextAndBlock(
1012
+ contextText,
1013
+ `Plan review FAILED${iterInfo}. ` +
1014
+ `Critical issues: ${topIssuesText}. ` +
1015
+ `IMPORTANT: Read \`${highIssuesPath}\` for ALL high-severity issues — ` +
1016
+ `this file contains only the most critical findings, no noise. ` +
1017
+ `${REVIEWER_CAVEAT} ` +
1018
+ `Revise the plan to address these issues, then call ExitPlanMode again. ` +
1019
+ RESUBMIT_INSTRUCTION,
1020
+ );
1021
+ } else {
1022
+ markPlanReviewed(sessionId, planHash, base, HOOK, iterationState, "allow");
1023
+ emitContext(contextText);
1024
+ }
1025
+ }
1026
+
1027
+ runHookAsync(main, "cc_native_plan_review");