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.
- 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 +107 -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/git-state.ts +1 -1
- 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 +15 -2
- 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 +142 -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 +43 -23
- 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 +158 -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 +345 -0
- package/dist/templates/_shared/scripts/save_handoff.ts +3 -3
- package/dist/templates/_shared/scripts/status_line.ts +687 -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 +1027 -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 +156 -0
- package/dist/templates/cc-native/_cc-native/lib-ts/artifacts.ts +792 -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 +144 -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/corroboration.ts +115 -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 +120 -0
- package/dist/templates/cc-native/_cc-native/lib-ts/json-parser.ts +168 -0
- package/dist/templates/cc-native/_cc-native/lib-ts/nul +3 -0
- package/dist/templates/cc-native/_cc-native/lib-ts/orchestrator.ts +250 -0
- package/dist/templates/cc-native/_cc-native/lib-ts/reviewers/agent.ts +275 -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 +107 -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 +240 -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 +385 -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 +14 -1
- package/oclif.manifest.json +1 -1
- package/package.json +2 -2
- 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
|
@@ -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");
|