@planningo/duul 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.ko.md +438 -0
- package/README.md +463 -0
- package/build/index.d.ts +2 -0
- package/build/index.js +18 -0
- package/build/prompts/code-review-system.d.ts +9 -0
- package/build/prompts/code-review-system.js +116 -0
- package/build/prompts/execution-partition-system.d.ts +11 -0
- package/build/prompts/execution-partition-system.js +76 -0
- package/build/prompts/plan-review-system.d.ts +29 -0
- package/build/prompts/plan-review-system.js +175 -0
- package/build/schemas/code-review.d.ts +514 -0
- package/build/schemas/code-review.js +175 -0
- package/build/schemas/common.d.ts +118 -0
- package/build/schemas/common.js +64 -0
- package/build/schemas/execution-partition.d.ts +597 -0
- package/build/schemas/execution-partition.js +107 -0
- package/build/schemas/plan-review.d.ts +523 -0
- package/build/schemas/plan-review.js +175 -0
- package/build/services/filesystem-tools.d.ts +6 -0
- package/build/services/filesystem-tools.js +39 -0
- package/build/services/filesystem.d.ts +69 -0
- package/build/services/filesystem.js +609 -0
- package/build/services/pricing.d.ts +8 -0
- package/build/services/pricing.js +105 -0
- package/build/services/providers/anthropic.d.ts +28 -0
- package/build/services/providers/anthropic.js +431 -0
- package/build/services/providers/google.d.ts +28 -0
- package/build/services/providers/google.js +358 -0
- package/build/services/providers/openai.d.ts +22 -0
- package/build/services/providers/openai.js +395 -0
- package/build/services/providers/types.d.ts +82 -0
- package/build/services/providers/types.js +1 -0
- package/build/services/review-gates.d.ts +83 -0
- package/build/services/review-gates.js +200 -0
- package/build/services/review-limits.d.ts +36 -0
- package/build/services/review-limits.js +65 -0
- package/build/services/reviewer.d.ts +30 -0
- package/build/services/reviewer.js +243 -0
- package/build/services/usage-logger.d.ts +2 -0
- package/build/services/usage-logger.js +42 -0
- package/build/tools/code-review.d.ts +2 -0
- package/build/tools/code-review.js +178 -0
- package/build/tools/execution-partition.d.ts +2 -0
- package/build/tools/execution-partition.js +146 -0
- package/build/tools/plan-review.d.ts +2 -0
- package/build/tools/plan-review.js +183 -0
- package/package.json +65 -0
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { CodeReviewInputSchema, CodeReviewOutputSchema, CodeReviewMcpOutputSchema, } from '../schemas/code-review.js';
|
|
2
|
+
import { getCodeReviewSystemPrompt, formatCodeReviewUserMessage } from '../prompts/code-review-system.js';
|
|
3
|
+
import { callReview } from '../services/reviewer.js';
|
|
4
|
+
import { resolveWorkspaceScope, getGitDiff } from '../services/filesystem.js';
|
|
5
|
+
import { computeIterationMeta, isIterationLimitExceeded } from '../services/review-limits.js';
|
|
6
|
+
import { logUsage } from '../services/usage-logger.js';
|
|
7
|
+
import { applyGates } from '../services/review-gates.js';
|
|
8
|
+
const MAX_INLINE_DIFF_CHARS = 50_000;
|
|
9
|
+
const ZERO_USAGE = { input_tokens: 0, output_tokens: 0, total_tokens: 0, api_calls: 0, provider: 'none', model: 'none', estimated_cost_usd: null };
|
|
10
|
+
export function registerCodeReviewTool(server) {
|
|
11
|
+
server.registerTool('request_code_review', {
|
|
12
|
+
title: 'DUUL Code Review (Strict QA)',
|
|
13
|
+
description: 'DUUL Phase 2: Submit code for review by an LLM acting as a Strict QA Engineer. ' +
|
|
14
|
+
'Requires the approved plan for context. Returns blocking issues, vulnerabilities, ' +
|
|
15
|
+
'and optionally an optimized code snippet, or approval.',
|
|
16
|
+
inputSchema: CodeReviewInputSchema,
|
|
17
|
+
outputSchema: CodeReviewMcpOutputSchema,
|
|
18
|
+
}, async (input) => {
|
|
19
|
+
try {
|
|
20
|
+
const args = input;
|
|
21
|
+
const iterMeta = computeIterationMeta('code', args.iteration_count, args.max_review_iterations);
|
|
22
|
+
// Short-circuit if iteration limit exceeded
|
|
23
|
+
if (isIterationLimitExceeded('code', args.iteration_count, args.max_review_iterations)) {
|
|
24
|
+
console.error(`[duul] Code review iteration limit exceeded: ${args.iteration_count} > ${iterMeta.iteration_limit}`);
|
|
25
|
+
const limitResult = {
|
|
26
|
+
verdict: 'REVISE',
|
|
27
|
+
review_status: 'incomplete',
|
|
28
|
+
confidence: 0,
|
|
29
|
+
requires_human_review: true,
|
|
30
|
+
logic_validation: `Iteration limit reached (${iterMeta.iteration_count}/${iterMeta.iteration_limit}). Human review required to continue.`,
|
|
31
|
+
blocking_issues: [],
|
|
32
|
+
merge_blockers: null,
|
|
33
|
+
non_blocking_suggestions: [],
|
|
34
|
+
vulnerabilities: [],
|
|
35
|
+
optimized_snippet: null,
|
|
36
|
+
follow_up_todos: null,
|
|
37
|
+
missing_context: null,
|
|
38
|
+
evidence_files: null,
|
|
39
|
+
used_tools: null,
|
|
40
|
+
tool_exhaustion_reason: null,
|
|
41
|
+
user_original_request_echo: null,
|
|
42
|
+
symptom_impact: null,
|
|
43
|
+
symptom_match_notes: null,
|
|
44
|
+
gates_tripped: null,
|
|
45
|
+
review_id: '',
|
|
46
|
+
...iterMeta,
|
|
47
|
+
token_usage: ZERO_USAGE,
|
|
48
|
+
};
|
|
49
|
+
return {
|
|
50
|
+
content: [{ type: 'text', text: JSON.stringify(limitResult, null, 2) }],
|
|
51
|
+
structuredContent: limitResult,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
const scope = resolveWorkspaceScope(args);
|
|
55
|
+
// Auto-generate git diff if not provided
|
|
56
|
+
let gitDiff = args.git_diff;
|
|
57
|
+
if (!gitDiff && scope?.root && args.changed_files?.length) {
|
|
58
|
+
try {
|
|
59
|
+
const diffResult = await getGitDiff(scope.root, args.git_diff_base ?? 'HEAD', args.changed_files, scope);
|
|
60
|
+
if (diffResult && !diffResult.startsWith('Error') && !diffResult.startsWith('No differences')) {
|
|
61
|
+
gitDiff = diffResult.length > MAX_INLINE_DIFF_CHARS
|
|
62
|
+
? diffResult.slice(0, MAX_INLINE_DIFF_CHARS) + `\n\n[truncated — diff exceeded ${MAX_INLINE_DIFF_CHARS} chars]`
|
|
63
|
+
: diffResult;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
// Diff generation is best-effort, continue without it
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
const systemPrompt = getCodeReviewSystemPrompt();
|
|
71
|
+
const userMessage = formatCodeReviewUserMessage(args.code, args.approved_plan, args.file_path, args.dependencies, args.relevant_code, args.notes_to_reviewer, {
|
|
72
|
+
workingDirectories: args.working_directories,
|
|
73
|
+
linkedRoots: args.linked_roots,
|
|
74
|
+
changedFiles: args.changed_files,
|
|
75
|
+
entrypoints: args.entrypoints,
|
|
76
|
+
artifactRefs: args.artifact_refs,
|
|
77
|
+
gitHeadSha: args.git_head_sha,
|
|
78
|
+
previousGitHeadSha: args.previous_git_head_sha,
|
|
79
|
+
workspaceName: args.workspace_name,
|
|
80
|
+
setupScriptPresent: args.setup_script_present,
|
|
81
|
+
runScriptPresent: args.run_script_present,
|
|
82
|
+
environmentFilesExpected: args.environment_files_expected,
|
|
83
|
+
gitDiff,
|
|
84
|
+
}, args.user_original_request);
|
|
85
|
+
const { parsed, reviewId, usage } = await callReview({
|
|
86
|
+
systemPrompt,
|
|
87
|
+
userMessage,
|
|
88
|
+
schemaName: 'code_review_output',
|
|
89
|
+
outputSchema: CodeReviewOutputSchema,
|
|
90
|
+
workspaceScope: scope,
|
|
91
|
+
previousReviewId: args.previous_review_id,
|
|
92
|
+
reviewerConfig: args.reviewer_config,
|
|
93
|
+
createFallback: (reason, usedTools) => ({
|
|
94
|
+
verdict: 'REVISE',
|
|
95
|
+
review_status: 'incomplete',
|
|
96
|
+
confidence: 0,
|
|
97
|
+
requires_human_review: true,
|
|
98
|
+
logic_validation: `Review could not be completed — tool loop exhausted (${reason}).`,
|
|
99
|
+
blocking_issues: [],
|
|
100
|
+
merge_blockers: null,
|
|
101
|
+
non_blocking_suggestions: [],
|
|
102
|
+
vulnerabilities: [],
|
|
103
|
+
optimized_snippet: null,
|
|
104
|
+
follow_up_todos: null,
|
|
105
|
+
missing_context: usedTools.length > 0 ? usedTools : ['No tools were called'],
|
|
106
|
+
evidence_files: null,
|
|
107
|
+
used_tools: usedTools,
|
|
108
|
+
tool_exhaustion_reason: reason,
|
|
109
|
+
user_original_request_echo: null,
|
|
110
|
+
symptom_impact: null,
|
|
111
|
+
symptom_match_notes: null,
|
|
112
|
+
gates_tripped: null,
|
|
113
|
+
}),
|
|
114
|
+
});
|
|
115
|
+
// Invariant: APPROVE with blocking_issues is always wrong — override to REVISE
|
|
116
|
+
let verdict = parsed.verdict === 'APPROVE' && parsed.blocking_issues?.length > 0
|
|
117
|
+
? 'REVISE'
|
|
118
|
+
: parsed.verdict;
|
|
119
|
+
if (verdict !== parsed.verdict) {
|
|
120
|
+
console.error(`[duul] Verdict overridden: APPROVE → REVISE (${parsed.blocking_issues.length} blocking issues)`);
|
|
121
|
+
}
|
|
122
|
+
// Post-LLM gates
|
|
123
|
+
const gates = applyGates({
|
|
124
|
+
phase: 'code',
|
|
125
|
+
userOriginalRequest: args.user_original_request,
|
|
126
|
+
notesToReviewer: args.notes_to_reviewer,
|
|
127
|
+
changedFiles: args.changed_files,
|
|
128
|
+
gitDiff,
|
|
129
|
+
artifactRefs: args.artifact_refs,
|
|
130
|
+
symptomImpact: parsed.symptom_impact,
|
|
131
|
+
});
|
|
132
|
+
let requires_human_review = parsed.requires_human_review;
|
|
133
|
+
let blocking_issues = parsed.blocking_issues;
|
|
134
|
+
if (gates.tripped.length > 0) {
|
|
135
|
+
if (gates.forcedVerdict === 'REVISE')
|
|
136
|
+
verdict = 'REVISE';
|
|
137
|
+
if (gates.forcedHumanReview)
|
|
138
|
+
requires_human_review = true;
|
|
139
|
+
blocking_issues = [...blocking_issues, ...gates.extraBlockingIssues];
|
|
140
|
+
console.error(`[duul] Code gates tripped: ${gates.tripped.join(', ')}`);
|
|
141
|
+
}
|
|
142
|
+
const result = {
|
|
143
|
+
...parsed,
|
|
144
|
+
verdict,
|
|
145
|
+
requires_human_review,
|
|
146
|
+
blocking_issues,
|
|
147
|
+
gates_tripped: gates.tripped.length > 0 ? gates.tripped : null,
|
|
148
|
+
review_id: reviewId,
|
|
149
|
+
...iterMeta,
|
|
150
|
+
token_usage: usage,
|
|
151
|
+
};
|
|
152
|
+
logUsage('code_review', result.token_usage, {
|
|
153
|
+
verdict,
|
|
154
|
+
review_id: reviewId,
|
|
155
|
+
iteration_count: iterMeta.iteration_count,
|
|
156
|
+
workspace_name: args.workspace_name,
|
|
157
|
+
gates_tripped: result.gates_tripped,
|
|
158
|
+
});
|
|
159
|
+
return {
|
|
160
|
+
content: [
|
|
161
|
+
{
|
|
162
|
+
type: 'text',
|
|
163
|
+
text: JSON.stringify(result, null, 2),
|
|
164
|
+
},
|
|
165
|
+
],
|
|
166
|
+
structuredContent: result,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
catch (error) {
|
|
170
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
171
|
+
console.error(`[duul] code-review error: ${message}`);
|
|
172
|
+
return {
|
|
173
|
+
content: [{ type: 'text', text: `Code review failed: ${message}` }],
|
|
174
|
+
isError: true,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { ExecutionPartitionInputSchema, ExecutionPartitionOutputSchema, ExecutionPartitionMcpOutputSchema, } from '../schemas/execution-partition.js';
|
|
2
|
+
import { getExecutionPartitionSystemPrompt, formatExecutionPartitionUserMessage } from '../prompts/execution-partition-system.js';
|
|
3
|
+
import { callReview } from '../services/reviewer.js';
|
|
4
|
+
import { resolveWorkspaceScope } from '../services/filesystem.js';
|
|
5
|
+
import { computeIterationMeta, isIterationLimitExceeded } from '../services/review-limits.js';
|
|
6
|
+
import { logUsage } from '../services/usage-logger.js';
|
|
7
|
+
const ZERO_USAGE = { input_tokens: 0, output_tokens: 0, total_tokens: 0, api_calls: 0, provider: 'none', model: 'none', estimated_cost_usd: null };
|
|
8
|
+
export function registerExecutionPartitionTool(server) {
|
|
9
|
+
server.registerTool('request_execution_partition', {
|
|
10
|
+
title: 'DUUL Execution Partition (Project Manager)',
|
|
11
|
+
description: 'DUUL optional: Partition an approved plan into executable subtasks with dependency graph, ' +
|
|
12
|
+
'spawn strategy, and handoff contracts. Use after plan review approval to ' +
|
|
13
|
+
'determine whether work can be parallelized across multiple agents/workspaces.',
|
|
14
|
+
inputSchema: ExecutionPartitionInputSchema,
|
|
15
|
+
outputSchema: ExecutionPartitionMcpOutputSchema,
|
|
16
|
+
}, async (input) => {
|
|
17
|
+
try {
|
|
18
|
+
const args = input;
|
|
19
|
+
const iterMeta = computeIterationMeta('partition', args.iteration_count, args.max_review_iterations);
|
|
20
|
+
// Short-circuit if iteration limit exceeded
|
|
21
|
+
if (isIterationLimitExceeded('partition', args.iteration_count, args.max_review_iterations)) {
|
|
22
|
+
console.error(`[duul] Partition iteration limit exceeded: ${args.iteration_count} > ${iterMeta.iteration_limit}`);
|
|
23
|
+
const limitResult = {
|
|
24
|
+
execution_mode: 'serial',
|
|
25
|
+
rationale: `Iteration limit reached (${iterMeta.iteration_count}/${iterMeta.iteration_limit}). Human review required to continue.`,
|
|
26
|
+
requires_human_checkpoint: true,
|
|
27
|
+
human_checkpoint_reasons: [`Iteration limit reached — ${iterMeta.iteration_count}/${iterMeta.iteration_limit} iterations used.`],
|
|
28
|
+
spawn_strategy: 'reuse_workspace',
|
|
29
|
+
handoff_artifact_pattern: '.context/subtasks/{subtask_id}.json',
|
|
30
|
+
subtask_result_schema_version: '1.0',
|
|
31
|
+
subtasks: [{
|
|
32
|
+
id: 'full-plan',
|
|
33
|
+
title: 'Execute full plan serially (limit reached)',
|
|
34
|
+
goal: args.approved_plan.slice(0, 200) + '...',
|
|
35
|
+
can_run_in_parallel: false,
|
|
36
|
+
depends_on: [],
|
|
37
|
+
workspace_name_hint: 'main',
|
|
38
|
+
spawn_strategy: 'reuse_workspace',
|
|
39
|
+
scope: {
|
|
40
|
+
working_directories: args.working_directories,
|
|
41
|
+
changed_files: args.changed_files,
|
|
42
|
+
entrypoints: args.entrypoints,
|
|
43
|
+
},
|
|
44
|
+
handoff_contract: [],
|
|
45
|
+
completion_criteria: ['All items in the approved plan are implemented'],
|
|
46
|
+
review_focus: ['Full plan implementation'],
|
|
47
|
+
risk_level: 'medium',
|
|
48
|
+
}],
|
|
49
|
+
global_checkpoints: [],
|
|
50
|
+
merge_order: ['full-plan'],
|
|
51
|
+
retry_policy: {
|
|
52
|
+
max_retries: 2,
|
|
53
|
+
on_review_revise: 'retry_subtask',
|
|
54
|
+
on_tool_exhaustion: 'retry_with_narrower_scope',
|
|
55
|
+
on_conflict: 'serialize_and_retry',
|
|
56
|
+
on_blocker: 'escalate_to_human',
|
|
57
|
+
on_max_retries_exceeded: 'abort_subtask_and_report',
|
|
58
|
+
},
|
|
59
|
+
review_id: '',
|
|
60
|
+
...iterMeta,
|
|
61
|
+
token_usage: ZERO_USAGE,
|
|
62
|
+
};
|
|
63
|
+
return {
|
|
64
|
+
content: [{ type: 'text', text: JSON.stringify(limitResult, null, 2) }],
|
|
65
|
+
structuredContent: limitResult,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
const scope = resolveWorkspaceScope(args);
|
|
69
|
+
const systemPrompt = getExecutionPartitionSystemPrompt();
|
|
70
|
+
const userMessage = formatExecutionPartitionUserMessage(args.approved_plan, args.constraints, {
|
|
71
|
+
changedFiles: args.changed_files,
|
|
72
|
+
entrypoints: args.entrypoints,
|
|
73
|
+
artifactRefs: args.artifact_refs,
|
|
74
|
+
workingDirectories: args.working_directories,
|
|
75
|
+
}, args.max_parallelism);
|
|
76
|
+
const { parsed, reviewId, usage } = await callReview({
|
|
77
|
+
systemPrompt,
|
|
78
|
+
userMessage,
|
|
79
|
+
schemaName: 'execution_partition_output',
|
|
80
|
+
outputSchema: ExecutionPartitionOutputSchema,
|
|
81
|
+
workspaceScope: scope,
|
|
82
|
+
previousReviewId: args.previous_review_id,
|
|
83
|
+
reviewerConfig: args.reviewer_config,
|
|
84
|
+
createFallback: (_reason, _usedTools) => ({
|
|
85
|
+
execution_mode: 'serial',
|
|
86
|
+
rationale: 'Partition could not be completed — tool loop exhausted. Defaulting to serial execution.',
|
|
87
|
+
requires_human_checkpoint: true,
|
|
88
|
+
human_checkpoint_reasons: ['Partition was incomplete — human should verify before proceeding.'],
|
|
89
|
+
spawn_strategy: 'reuse_workspace',
|
|
90
|
+
handoff_artifact_pattern: '.context/subtasks/{subtask_id}.json',
|
|
91
|
+
subtask_result_schema_version: '1.0',
|
|
92
|
+
subtasks: [{
|
|
93
|
+
id: 'full-plan',
|
|
94
|
+
title: 'Execute full plan serially',
|
|
95
|
+
goal: args.approved_plan.slice(0, 200) + '...',
|
|
96
|
+
can_run_in_parallel: false,
|
|
97
|
+
depends_on: [],
|
|
98
|
+
workspace_name_hint: 'main',
|
|
99
|
+
spawn_strategy: 'reuse_workspace',
|
|
100
|
+
scope: {
|
|
101
|
+
working_directories: args.working_directories,
|
|
102
|
+
changed_files: args.changed_files,
|
|
103
|
+
entrypoints: args.entrypoints,
|
|
104
|
+
},
|
|
105
|
+
handoff_contract: [],
|
|
106
|
+
completion_criteria: ['All items in the approved plan are implemented'],
|
|
107
|
+
review_focus: ['Full plan implementation'],
|
|
108
|
+
risk_level: 'medium',
|
|
109
|
+
}],
|
|
110
|
+
global_checkpoints: [],
|
|
111
|
+
merge_order: ['full-plan'],
|
|
112
|
+
retry_policy: {
|
|
113
|
+
max_retries: 2,
|
|
114
|
+
on_review_revise: 'retry_subtask',
|
|
115
|
+
on_tool_exhaustion: 'retry_with_narrower_scope',
|
|
116
|
+
on_conflict: 'serialize_and_retry',
|
|
117
|
+
on_blocker: 'escalate_to_human',
|
|
118
|
+
on_max_retries_exceeded: 'abort_subtask_and_report',
|
|
119
|
+
},
|
|
120
|
+
}),
|
|
121
|
+
});
|
|
122
|
+
const result = { ...parsed, review_id: reviewId, ...iterMeta, token_usage: usage };
|
|
123
|
+
logUsage('execution_partition', result.token_usage, {
|
|
124
|
+
review_id: reviewId,
|
|
125
|
+
iteration_count: iterMeta.iteration_count,
|
|
126
|
+
});
|
|
127
|
+
return {
|
|
128
|
+
content: [
|
|
129
|
+
{
|
|
130
|
+
type: 'text',
|
|
131
|
+
text: JSON.stringify(result, null, 2),
|
|
132
|
+
},
|
|
133
|
+
],
|
|
134
|
+
structuredContent: result,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
catch (error) {
|
|
138
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
139
|
+
console.error(`[duul] execution-partition error: ${message}`);
|
|
140
|
+
return {
|
|
141
|
+
content: [{ type: 'text', text: `Execution partition failed: ${message}` }],
|
|
142
|
+
isError: true,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { PlanReviewInputSchema, PlanReviewOutputSchema, PlanReviewMcpOutputSchema, } from '../schemas/plan-review.js';
|
|
2
|
+
import { getPlanReviewSystemPrompt, formatPlanReviewUserMessage } from '../prompts/plan-review-system.js';
|
|
3
|
+
import { callReview } from '../services/reviewer.js';
|
|
4
|
+
import { resolveWorkspaceScope, getGitDiff } from '../services/filesystem.js';
|
|
5
|
+
import { computeIterationMeta, isIterationLimitExceeded } from '../services/review-limits.js';
|
|
6
|
+
import { logUsage } from '../services/usage-logger.js';
|
|
7
|
+
import { applyGates } from '../services/review-gates.js';
|
|
8
|
+
const MAX_INLINE_DIFF_CHARS = 50_000;
|
|
9
|
+
const ZERO_USAGE = { input_tokens: 0, output_tokens: 0, total_tokens: 0, api_calls: 0, provider: 'none', model: 'none', estimated_cost_usd: null };
|
|
10
|
+
export function registerPlanReviewTool(server) {
|
|
11
|
+
server.registerTool('request_plan_review', {
|
|
12
|
+
title: 'DUUL Plan Review (Senior Architect)',
|
|
13
|
+
description: 'DUUL Phase 1: Submit a development plan for review by an LLM acting as a Senior Architect. ' +
|
|
14
|
+
'Returns structured feedback with blocking issues, edge cases, and implementation checklist, or approval.',
|
|
15
|
+
inputSchema: PlanReviewInputSchema,
|
|
16
|
+
outputSchema: PlanReviewMcpOutputSchema,
|
|
17
|
+
}, async (input) => {
|
|
18
|
+
try {
|
|
19
|
+
const args = input;
|
|
20
|
+
const iterMeta = computeIterationMeta('plan', args.iteration_count, args.max_review_iterations);
|
|
21
|
+
// Short-circuit if iteration limit exceeded
|
|
22
|
+
if (isIterationLimitExceeded('plan', args.iteration_count, args.max_review_iterations)) {
|
|
23
|
+
console.error(`[duul] Plan review iteration limit exceeded: ${args.iteration_count} > ${iterMeta.iteration_limit}`);
|
|
24
|
+
const limitResult = {
|
|
25
|
+
verdict: 'REVISE',
|
|
26
|
+
review_status: 'incomplete',
|
|
27
|
+
confidence: 0,
|
|
28
|
+
requires_human_review: true,
|
|
29
|
+
architectural_analysis: `Iteration limit reached (${iterMeta.iteration_count}/${iterMeta.iteration_limit}). Human review required to continue.`,
|
|
30
|
+
blocking_issues: [],
|
|
31
|
+
merge_blockers: null,
|
|
32
|
+
non_blocking_suggestions: [],
|
|
33
|
+
edge_cases: [],
|
|
34
|
+
checklist_for_implementation: [],
|
|
35
|
+
follow_up_todos: null,
|
|
36
|
+
missing_context: null,
|
|
37
|
+
evidence_files: null,
|
|
38
|
+
used_tools: null,
|
|
39
|
+
tool_exhaustion_reason: null,
|
|
40
|
+
parallelization_hint: null,
|
|
41
|
+
coordination_risks: null,
|
|
42
|
+
recommended_subtask_boundaries: null,
|
|
43
|
+
user_original_request_echo: null,
|
|
44
|
+
symptom_impact: null,
|
|
45
|
+
symptom_match_notes: null,
|
|
46
|
+
gates_tripped: null,
|
|
47
|
+
review_id: '',
|
|
48
|
+
...iterMeta,
|
|
49
|
+
token_usage: ZERO_USAGE,
|
|
50
|
+
};
|
|
51
|
+
return {
|
|
52
|
+
content: [{ type: 'text', text: JSON.stringify(limitResult, null, 2) }],
|
|
53
|
+
structuredContent: limitResult,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
const scope = resolveWorkspaceScope(args);
|
|
57
|
+
// Auto-generate git diff if not provided
|
|
58
|
+
let gitDiff = args.git_diff;
|
|
59
|
+
if (!gitDiff && scope?.root && args.changed_files?.length) {
|
|
60
|
+
try {
|
|
61
|
+
const diffResult = await getGitDiff(scope.root, args.git_diff_base ?? 'HEAD', args.changed_files, scope);
|
|
62
|
+
if (diffResult && !diffResult.startsWith('Error') && !diffResult.startsWith('No differences')) {
|
|
63
|
+
gitDiff = diffResult.length > MAX_INLINE_DIFF_CHARS
|
|
64
|
+
? diffResult.slice(0, MAX_INLINE_DIFF_CHARS) + `\n\n[truncated — diff exceeded ${MAX_INLINE_DIFF_CHARS} chars]`
|
|
65
|
+
: diffResult;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
// Diff generation is best-effort, continue without it
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
const systemPrompt = getPlanReviewSystemPrompt();
|
|
73
|
+
const userMessage = formatPlanReviewUserMessage(args.plan, args.project_context, args.constraints, args.notes_to_reviewer, {
|
|
74
|
+
workingDirectories: args.working_directories,
|
|
75
|
+
linkedRoots: args.linked_roots,
|
|
76
|
+
changedFiles: args.changed_files,
|
|
77
|
+
entrypoints: args.entrypoints,
|
|
78
|
+
artifactRefs: args.artifact_refs,
|
|
79
|
+
gitHeadSha: args.git_head_sha,
|
|
80
|
+
previousGitHeadSha: args.previous_git_head_sha,
|
|
81
|
+
workspaceName: args.workspace_name,
|
|
82
|
+
setupScriptPresent: args.setup_script_present,
|
|
83
|
+
runScriptPresent: args.run_script_present,
|
|
84
|
+
environmentFilesExpected: args.environment_files_expected,
|
|
85
|
+
gitDiff,
|
|
86
|
+
}, args.user_original_request);
|
|
87
|
+
const { parsed, reviewId, usage } = await callReview({
|
|
88
|
+
systemPrompt,
|
|
89
|
+
userMessage,
|
|
90
|
+
schemaName: 'plan_review_output',
|
|
91
|
+
outputSchema: PlanReviewOutputSchema,
|
|
92
|
+
workspaceScope: scope,
|
|
93
|
+
previousReviewId: args.previous_review_id,
|
|
94
|
+
reviewerConfig: args.reviewer_config,
|
|
95
|
+
createFallback: (reason, usedTools) => ({
|
|
96
|
+
verdict: 'REVISE',
|
|
97
|
+
review_status: 'incomplete',
|
|
98
|
+
confidence: 0,
|
|
99
|
+
requires_human_review: true,
|
|
100
|
+
architectural_analysis: `Review could not be completed — tool loop exhausted (${reason}).`,
|
|
101
|
+
blocking_issues: [],
|
|
102
|
+
merge_blockers: null,
|
|
103
|
+
non_blocking_suggestions: [],
|
|
104
|
+
edge_cases: [],
|
|
105
|
+
checklist_for_implementation: [],
|
|
106
|
+
follow_up_todos: null,
|
|
107
|
+
missing_context: usedTools.length > 0 ? usedTools : ['No tools were called'],
|
|
108
|
+
evidence_files: null,
|
|
109
|
+
used_tools: usedTools,
|
|
110
|
+
tool_exhaustion_reason: reason,
|
|
111
|
+
parallelization_hint: null,
|
|
112
|
+
coordination_risks: null,
|
|
113
|
+
recommended_subtask_boundaries: null,
|
|
114
|
+
user_original_request_echo: null,
|
|
115
|
+
symptom_impact: null,
|
|
116
|
+
symptom_match_notes: null,
|
|
117
|
+
gates_tripped: null,
|
|
118
|
+
}),
|
|
119
|
+
});
|
|
120
|
+
// Invariant: APPROVE with blocking_issues is always wrong — override to REVISE
|
|
121
|
+
let verdict = parsed.verdict === 'APPROVE' && parsed.blocking_issues?.length > 0
|
|
122
|
+
? 'REVISE'
|
|
123
|
+
: parsed.verdict;
|
|
124
|
+
if (verdict !== parsed.verdict) {
|
|
125
|
+
console.error(`[duul] Verdict overridden: APPROVE → REVISE (${parsed.blocking_issues.length} blocking issues)`);
|
|
126
|
+
}
|
|
127
|
+
// Post-LLM gates
|
|
128
|
+
const gates = applyGates({
|
|
129
|
+
phase: 'plan',
|
|
130
|
+
userOriginalRequest: args.user_original_request,
|
|
131
|
+
notesToReviewer: args.notes_to_reviewer,
|
|
132
|
+
changedFiles: args.changed_files,
|
|
133
|
+
gitDiff,
|
|
134
|
+
artifactRefs: args.artifact_refs,
|
|
135
|
+
symptomImpact: parsed.symptom_impact,
|
|
136
|
+
});
|
|
137
|
+
let requires_human_review = parsed.requires_human_review;
|
|
138
|
+
let blocking_issues = parsed.blocking_issues;
|
|
139
|
+
if (gates.tripped.length > 0) {
|
|
140
|
+
if (gates.forcedVerdict === 'REVISE')
|
|
141
|
+
verdict = 'REVISE';
|
|
142
|
+
if (gates.forcedHumanReview)
|
|
143
|
+
requires_human_review = true;
|
|
144
|
+
blocking_issues = [...blocking_issues, ...gates.extraBlockingIssues];
|
|
145
|
+
console.error(`[duul] Plan gates tripped: ${gates.tripped.join(', ')}`);
|
|
146
|
+
}
|
|
147
|
+
const result = {
|
|
148
|
+
...parsed,
|
|
149
|
+
verdict,
|
|
150
|
+
requires_human_review,
|
|
151
|
+
blocking_issues,
|
|
152
|
+
gates_tripped: gates.tripped.length > 0 ? gates.tripped : null,
|
|
153
|
+
review_id: reviewId,
|
|
154
|
+
...iterMeta,
|
|
155
|
+
token_usage: usage,
|
|
156
|
+
};
|
|
157
|
+
logUsage('plan_review', result.token_usage, {
|
|
158
|
+
verdict,
|
|
159
|
+
review_id: reviewId,
|
|
160
|
+
iteration_count: iterMeta.iteration_count,
|
|
161
|
+
workspace_name: args.workspace_name,
|
|
162
|
+
gates_tripped: result.gates_tripped,
|
|
163
|
+
});
|
|
164
|
+
return {
|
|
165
|
+
content: [
|
|
166
|
+
{
|
|
167
|
+
type: 'text',
|
|
168
|
+
text: JSON.stringify(result, null, 2),
|
|
169
|
+
},
|
|
170
|
+
],
|
|
171
|
+
structuredContent: result,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
catch (error) {
|
|
175
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
176
|
+
console.error(`[duul] plan-review error: ${message}`);
|
|
177
|
+
return {
|
|
178
|
+
content: [{ type: 'text', text: `Plan review failed: ${message}` }],
|
|
179
|
+
isError: true,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@planningo/duul",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "DUUL — Dual-phase Upfront-plan & Unit-verify Loop. MCP server for LLM peer review of plans and code.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "build/index.js",
|
|
7
|
+
"bin": {
|
|
8
|
+
"duul": "build/index.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"build",
|
|
12
|
+
"!build/__tests__",
|
|
13
|
+
"!build/**/*.test.js",
|
|
14
|
+
"!build/**/*.test.d.ts",
|
|
15
|
+
"README.md",
|
|
16
|
+
"README.ko.md",
|
|
17
|
+
"LICENSE"
|
|
18
|
+
],
|
|
19
|
+
"scripts": {
|
|
20
|
+
"build": "tsc",
|
|
21
|
+
"start": "node build/index.js",
|
|
22
|
+
"test": "tsc && node --test build/services/*.test.js build/__tests__/*.test.js",
|
|
23
|
+
"token-report": "node scripts/token-report.mjs",
|
|
24
|
+
"prepublishOnly": "npm run build",
|
|
25
|
+
"release": "changeset publish"
|
|
26
|
+
},
|
|
27
|
+
"keywords": [
|
|
28
|
+
"mcp",
|
|
29
|
+
"model-context-protocol",
|
|
30
|
+
"claude",
|
|
31
|
+
"claude-code",
|
|
32
|
+
"anthropic",
|
|
33
|
+
"openai",
|
|
34
|
+
"llm",
|
|
35
|
+
"code-review",
|
|
36
|
+
"peer-review",
|
|
37
|
+
"duul"
|
|
38
|
+
],
|
|
39
|
+
"author": "Planningo",
|
|
40
|
+
"license": "MIT",
|
|
41
|
+
"homepage": "https://github.com/Planningo/duul#readme",
|
|
42
|
+
"repository": {
|
|
43
|
+
"type": "git",
|
|
44
|
+
"url": "git+https://github.com/Planningo/duul.git"
|
|
45
|
+
},
|
|
46
|
+
"bugs": {
|
|
47
|
+
"url": "https://github.com/Planningo/duul/issues"
|
|
48
|
+
},
|
|
49
|
+
"engines": {
|
|
50
|
+
"node": ">=20"
|
|
51
|
+
},
|
|
52
|
+
"publishConfig": {
|
|
53
|
+
"access": "public"
|
|
54
|
+
},
|
|
55
|
+
"dependencies": {
|
|
56
|
+
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
57
|
+
"openai": "^6.1.0",
|
|
58
|
+
"zod": "^3.25.0"
|
|
59
|
+
},
|
|
60
|
+
"devDependencies": {
|
|
61
|
+
"@changesets/cli": "^2.31.0",
|
|
62
|
+
"@types/node": "^22.0.0",
|
|
63
|
+
"typescript": "^5.8.0"
|
|
64
|
+
}
|
|
65
|
+
}
|