@planningo/duul 1.0.0 → 1.1.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.
@@ -1,8 +1,8 @@
1
1
  import { CodeReviewInputSchema, CodeReviewOutputSchema, CodeReviewMcpOutputSchema, } from '../schemas/code-review.js';
2
2
  import { getCodeReviewSystemPrompt, formatCodeReviewUserMessage } from '../prompts/code-review-system.js';
3
3
  import { callReview } from '../services/reviewer.js';
4
- import { resolveWorkspaceScope, getGitDiff } from '../services/filesystem.js';
5
- import { computeIterationMeta, isIterationLimitExceeded } from '../services/review-limits.js';
4
+ import { resolveWorkspaceScope, getGitDiff, resolveInlineOrFile } from '../services/filesystem.js';
5
+ import { computeIterationMeta, isIterationLimitExceeded, computeCostWarning } from '../services/review-limits.js';
6
6
  import { logUsage } from '../services/usage-logger.js';
7
7
  import { applyGates } from '../services/review-gates.js';
8
8
  const MAX_INLINE_DIFF_CHARS = 50_000;
@@ -10,14 +10,55 @@ const ZERO_USAGE = { input_tokens: 0, output_tokens: 0, total_tokens: 0, api_cal
10
10
  export function registerCodeReviewTool(server) {
11
11
  server.registerTool('request_code_review', {
12
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.',
13
+ description: 'DUUL Phase 2: Submit code for strict QA review. ' +
14
+ 'Provide the code EITHER inline via `code` OR (preferred for large content) via `code_file`, and the ' +
15
+ 'Phase 1 plan EITHER inline via `approved_plan` OR via `approved_plan_file` (relative paths, e.g. ".duul/code.md" / ".duul/plan.md") plus `workspace_root`. ' +
16
+ 'Exactly one of code/code_file and one of approved_plan/approved_plan_file are required. ' +
17
+ 'Optional: workspace_root, file_path, changed_files, artifact_refs, previous_review_id, iteration_count. ' +
18
+ 'Returns blocking issues, vulnerabilities, optimized snippet, or APPROVE verdict.',
16
19
  inputSchema: CodeReviewInputSchema,
17
20
  outputSchema: CodeReviewMcpOutputSchema,
18
21
  }, async (input) => {
19
22
  try {
20
23
  const args = input;
24
+ const scope = resolveWorkspaceScope(args);
25
+ // Resolve code and approved_plan from inline values or *_file escape hatches.
26
+ let codeText;
27
+ let approvedPlanText;
28
+ try {
29
+ codeText = await resolveInlineOrFile({ inline: args.code, file: args.code_file, scope, label: 'code' });
30
+ approvedPlanText = await resolveInlineOrFile({
31
+ inline: args.approved_plan,
32
+ file: args.approved_plan_file,
33
+ scope,
34
+ label: 'approved_plan',
35
+ });
36
+ }
37
+ catch (readErr) {
38
+ const reason = readErr instanceof Error ? readErr.message : String(readErr);
39
+ console.error(`[duul] code-review file read failed: ${reason}`);
40
+ return {
41
+ content: [{ type: 'text', text: `ERROR: could not read a *_file argument. ${reason}` }],
42
+ isError: true,
43
+ };
44
+ }
45
+ if (typeof codeText !== 'string' ||
46
+ codeText.trim().length < 5 ||
47
+ typeof approvedPlanText !== 'string' ||
48
+ approvedPlanText.trim().length < 20) {
49
+ const message = 'ERROR: `code` and `approved_plan` are both required. ' +
50
+ '`code` must contain the actual code being reviewed (min 5 chars). ' +
51
+ '`approved_plan` must contain the full plan text approved in Phase 1 (min 20 chars). ' +
52
+ 'You called request_code_review without usable content. ' +
53
+ 'Inline them — { "code": "<your code>", "approved_plan": "<plan text>", ... } — or, for large content, ' +
54
+ 'write each to a file and pass { "code_file": ".duul/code.md", "approved_plan_file": ".duul/plan.md", "workspace_root": "<absolute path>" }. ' +
55
+ 'Do NOT call this tool again with an empty input.';
56
+ console.error(`[duul] code-review rejected: missing/empty code or approved_plan content`);
57
+ return {
58
+ content: [{ type: 'text', text: message }],
59
+ isError: true,
60
+ };
61
+ }
21
62
  const iterMeta = computeIterationMeta('code', args.iteration_count, args.max_review_iterations);
22
63
  // Short-circuit if iteration limit exceeded
23
64
  if (isIterationLimitExceeded('code', args.iteration_count, args.max_review_iterations)) {
@@ -44,6 +85,7 @@ export function registerCodeReviewTool(server) {
44
85
  gates_tripped: null,
45
86
  review_id: '',
46
87
  ...iterMeta,
88
+ cost_warning: null,
47
89
  token_usage: ZERO_USAGE,
48
90
  };
49
91
  return {
@@ -51,7 +93,6 @@ export function registerCodeReviewTool(server) {
51
93
  structuredContent: limitResult,
52
94
  };
53
95
  }
54
- const scope = resolveWorkspaceScope(args);
55
96
  // Auto-generate git diff if not provided
56
97
  let gitDiff = args.git_diff;
57
98
  if (!gitDiff && scope?.root && args.changed_files?.length) {
@@ -68,7 +109,7 @@ export function registerCodeReviewTool(server) {
68
109
  }
69
110
  }
70
111
  const systemPrompt = getCodeReviewSystemPrompt();
71
- const userMessage = formatCodeReviewUserMessage(args.code, args.approved_plan, args.file_path, args.dependencies, args.relevant_code, args.notes_to_reviewer, {
112
+ const userMessage = formatCodeReviewUserMessage(codeText, approvedPlanText, args.file_path, args.dependencies, args.relevant_code, args.notes_to_reviewer, {
72
113
  workingDirectories: args.working_directories,
73
114
  linkedRoots: args.linked_roots,
74
115
  changedFiles: args.changed_files,
@@ -89,6 +130,7 @@ export function registerCodeReviewTool(server) {
89
130
  outputSchema: CodeReviewOutputSchema,
90
131
  workspaceScope: scope,
91
132
  previousReviewId: args.previous_review_id,
133
+ toolName: 'code',
92
134
  reviewerConfig: args.reviewer_config,
93
135
  createFallback: (reason, usedTools) => ({
94
136
  verdict: 'REVISE',
@@ -147,6 +189,7 @@ export function registerCodeReviewTool(server) {
147
189
  gates_tripped: gates.tripped.length > 0 ? gates.tripped : null,
148
190
  review_id: reviewId,
149
191
  ...iterMeta,
192
+ cost_warning: computeCostWarning(iterMeta, usage.estimated_cost_usd),
150
193
  token_usage: usage,
151
194
  };
152
195
  logUsage('code_review', result.token_usage, {
@@ -1,21 +1,59 @@
1
1
  import { ExecutionPartitionInputSchema, ExecutionPartitionOutputSchema, ExecutionPartitionMcpOutputSchema, } from '../schemas/execution-partition.js';
2
2
  import { getExecutionPartitionSystemPrompt, formatExecutionPartitionUserMessage } from '../prompts/execution-partition-system.js';
3
3
  import { callReview } from '../services/reviewer.js';
4
- import { resolveWorkspaceScope } from '../services/filesystem.js';
5
- import { computeIterationMeta, isIterationLimitExceeded } from '../services/review-limits.js';
4
+ import { resolveWorkspaceScope, resolveInlineOrFile } from '../services/filesystem.js';
5
+ import { computeIterationMeta, isIterationLimitExceeded, computeCostWarning } from '../services/review-limits.js';
6
6
  import { logUsage } from '../services/usage-logger.js';
7
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
8
  export function registerExecutionPartitionTool(server) {
9
9
  server.registerTool('request_execution_partition', {
10
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.',
11
+ description: 'DUUL optional: Partition an approved plan into executable subtasks. ' +
12
+ 'Provide the plan EITHER inline via `approved_plan` OR (preferred for large plans) via `approved_plan_file` ' +
13
+ '(relative path, e.g. ".duul/plan.md"). `workspace_root` (absolute path) is required. ' +
14
+ 'Exactly one of approved_plan/approved_plan_file is required. ' +
15
+ 'Optional: working_directories, changed_files, entrypoints, artifact_refs, max_parallelism, iteration_count. ' +
16
+ 'Returns dependency graph, spawn strategy, and handoff contracts.',
14
17
  inputSchema: ExecutionPartitionInputSchema,
15
18
  outputSchema: ExecutionPartitionMcpOutputSchema,
16
19
  }, async (input) => {
17
20
  try {
18
21
  const args = input;
22
+ const scope = resolveWorkspaceScope(args);
23
+ // Resolve approved_plan from inline value or approved_plan_file escape hatch.
24
+ let approvedPlanText;
25
+ try {
26
+ approvedPlanText = await resolveInlineOrFile({
27
+ inline: args.approved_plan,
28
+ file: args.approved_plan_file,
29
+ scope,
30
+ label: 'approved_plan',
31
+ });
32
+ }
33
+ catch (readErr) {
34
+ const reason = readErr instanceof Error ? readErr.message : String(readErr);
35
+ console.error(`[duul] execution-partition approved_plan_file read failed: ${reason}`);
36
+ return {
37
+ content: [{ type: 'text', text: `ERROR: could not read approved_plan_file. ${reason}` }],
38
+ isError: true,
39
+ };
40
+ }
41
+ if (typeof approvedPlanText !== 'string' ||
42
+ approvedPlanText.trim().length < 20 ||
43
+ typeof args.workspace_root !== 'string' ||
44
+ args.workspace_root.trim().length === 0) {
45
+ const message = 'ERROR: `approved_plan` and `workspace_root` are both required. ' +
46
+ '`approved_plan` must contain the full plan text (min 20 chars) — inline it, or pass approved_plan_file (e.g. ".duul/plan.md"). ' +
47
+ '`workspace_root` must be an absolute path. ' +
48
+ 'You called request_execution_partition with missing or empty content. ' +
49
+ 'Retry with: { "approved_plan": "<plan text>", "workspace_root": "<absolute path>" }. ' +
50
+ 'Do NOT call this tool again with an empty input.';
51
+ console.error(`[duul] execution-partition rejected: missing/empty approved_plan or workspace_root`);
52
+ return {
53
+ content: [{ type: 'text', text: message }],
54
+ isError: true,
55
+ };
56
+ }
19
57
  const iterMeta = computeIterationMeta('partition', args.iteration_count, args.max_review_iterations);
20
58
  // Short-circuit if iteration limit exceeded
21
59
  if (isIterationLimitExceeded('partition', args.iteration_count, args.max_review_iterations)) {
@@ -31,7 +69,7 @@ export function registerExecutionPartitionTool(server) {
31
69
  subtasks: [{
32
70
  id: 'full-plan',
33
71
  title: 'Execute full plan serially (limit reached)',
34
- goal: args.approved_plan.slice(0, 200) + '...',
72
+ goal: approvedPlanText.slice(0, 200) + '...',
35
73
  can_run_in_parallel: false,
36
74
  depends_on: [],
37
75
  workspace_name_hint: 'main',
@@ -58,6 +96,7 @@ export function registerExecutionPartitionTool(server) {
58
96
  },
59
97
  review_id: '',
60
98
  ...iterMeta,
99
+ cost_warning: null,
61
100
  token_usage: ZERO_USAGE,
62
101
  };
63
102
  return {
@@ -65,9 +104,8 @@ export function registerExecutionPartitionTool(server) {
65
104
  structuredContent: limitResult,
66
105
  };
67
106
  }
68
- const scope = resolveWorkspaceScope(args);
69
107
  const systemPrompt = getExecutionPartitionSystemPrompt();
70
- const userMessage = formatExecutionPartitionUserMessage(args.approved_plan, args.constraints, {
108
+ const userMessage = formatExecutionPartitionUserMessage(approvedPlanText, args.constraints, {
71
109
  changedFiles: args.changed_files,
72
110
  entrypoints: args.entrypoints,
73
111
  artifactRefs: args.artifact_refs,
@@ -80,6 +118,7 @@ export function registerExecutionPartitionTool(server) {
80
118
  outputSchema: ExecutionPartitionOutputSchema,
81
119
  workspaceScope: scope,
82
120
  previousReviewId: args.previous_review_id,
121
+ toolName: 'partition',
83
122
  reviewerConfig: args.reviewer_config,
84
123
  createFallback: (_reason, _usedTools) => ({
85
124
  execution_mode: 'serial',
@@ -92,7 +131,7 @@ export function registerExecutionPartitionTool(server) {
92
131
  subtasks: [{
93
132
  id: 'full-plan',
94
133
  title: 'Execute full plan serially',
95
- goal: args.approved_plan.slice(0, 200) + '...',
134
+ goal: approvedPlanText.slice(0, 200) + '...',
96
135
  can_run_in_parallel: false,
97
136
  depends_on: [],
98
137
  workspace_name_hint: 'main',
@@ -119,7 +158,13 @@ export function registerExecutionPartitionTool(server) {
119
158
  },
120
159
  }),
121
160
  });
122
- const result = { ...parsed, review_id: reviewId, ...iterMeta, token_usage: usage };
161
+ const result = {
162
+ ...parsed,
163
+ review_id: reviewId,
164
+ ...iterMeta,
165
+ cost_warning: computeCostWarning(iterMeta, usage.estimated_cost_usd),
166
+ token_usage: usage,
167
+ };
123
168
  logUsage('execution_partition', result.token_usage, {
124
169
  review_id: reviewId,
125
170
  iteration_count: iterMeta.iteration_count,
@@ -1,8 +1,8 @@
1
1
  import { PlanReviewInputSchema, PlanReviewOutputSchema, PlanReviewMcpOutputSchema, } from '../schemas/plan-review.js';
2
2
  import { getPlanReviewSystemPrompt, formatPlanReviewUserMessage } from '../prompts/plan-review-system.js';
3
3
  import { callReview } from '../services/reviewer.js';
4
- import { resolveWorkspaceScope, getGitDiff } from '../services/filesystem.js';
5
- import { computeIterationMeta, isIterationLimitExceeded } from '../services/review-limits.js';
4
+ import { resolveWorkspaceScope, getGitDiff, resolveInlineOrFile } from '../services/filesystem.js';
5
+ import { computeIterationMeta, isIterationLimitExceeded, computeCostWarning } from '../services/review-limits.js';
6
6
  import { logUsage } from '../services/usage-logger.js';
7
7
  import { applyGates } from '../services/review-gates.js';
8
8
  const MAX_INLINE_DIFF_CHARS = 50_000;
@@ -10,13 +10,43 @@ const ZERO_USAGE = { input_tokens: 0, output_tokens: 0, total_tokens: 0, api_cal
10
10
  export function registerPlanReviewTool(server) {
11
11
  server.registerTool('request_plan_review', {
12
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.',
13
+ description: 'DUUL Phase 1: Submit an implementation plan for senior-architect review. ' +
14
+ 'Provide the plan EITHER inline via `plan` OR (preferred for large plans) by writing it to a file ' +
15
+ 'and passing `plan_file` (relative path, e.g. ".duul/plan.md") plus `workspace_root`. ' +
16
+ 'Exactly one of plan/plan_file is required. ' +
17
+ 'Optional: project_context, changed_files, artifact_refs, user_original_request, previous_review_id, iteration_count. ' +
18
+ 'Returns blocking issues, edge cases, implementation checklist, or APPROVE verdict.',
15
19
  inputSchema: PlanReviewInputSchema,
16
20
  outputSchema: PlanReviewMcpOutputSchema,
17
21
  }, async (input) => {
18
22
  try {
19
23
  const args = input;
24
+ const scope = resolveWorkspaceScope(args);
25
+ // Resolve the plan from inline `plan` or from `plan_file` (large-plan escape hatch).
26
+ let planText;
27
+ try {
28
+ planText = await resolveInlineOrFile({ inline: args.plan, file: args.plan_file, scope, label: 'plan' });
29
+ }
30
+ catch (readErr) {
31
+ const reason = readErr instanceof Error ? readErr.message : String(readErr);
32
+ console.error(`[duul] plan-review plan_file read failed: ${reason}`);
33
+ return {
34
+ content: [{ type: 'text', text: `ERROR: could not read plan_file. ${reason}` }],
35
+ isError: true,
36
+ };
37
+ }
38
+ if (typeof planText !== 'string' || planText.trim().length < 20) {
39
+ const message = 'ERROR: a plan is required and must contain the full plan markdown (at least 20 chars). ' +
40
+ 'You called request_plan_review without usable plan content. ' +
41
+ 'Either inline it — { "plan": "<your complete plan text>", ... } — or, for a large plan, ' +
42
+ 'write it to a file first and pass { "plan_file": ".duul/plan.md", "workspace_root": "<absolute path>" }. ' +
43
+ 'Always include workspace_root and user_original_request. Do NOT call this tool again with an empty input.';
44
+ console.error(`[duul] plan-review rejected: missing/empty plan content`);
45
+ return {
46
+ content: [{ type: 'text', text: message }],
47
+ isError: true,
48
+ };
49
+ }
20
50
  const iterMeta = computeIterationMeta('plan', args.iteration_count, args.max_review_iterations);
21
51
  // Short-circuit if iteration limit exceeded
22
52
  if (isIterationLimitExceeded('plan', args.iteration_count, args.max_review_iterations)) {
@@ -46,6 +76,7 @@ export function registerPlanReviewTool(server) {
46
76
  gates_tripped: null,
47
77
  review_id: '',
48
78
  ...iterMeta,
79
+ cost_warning: null,
49
80
  token_usage: ZERO_USAGE,
50
81
  };
51
82
  return {
@@ -53,7 +84,6 @@ export function registerPlanReviewTool(server) {
53
84
  structuredContent: limitResult,
54
85
  };
55
86
  }
56
- const scope = resolveWorkspaceScope(args);
57
87
  // Auto-generate git diff if not provided
58
88
  let gitDiff = args.git_diff;
59
89
  if (!gitDiff && scope?.root && args.changed_files?.length) {
@@ -70,7 +100,7 @@ export function registerPlanReviewTool(server) {
70
100
  }
71
101
  }
72
102
  const systemPrompt = getPlanReviewSystemPrompt();
73
- const userMessage = formatPlanReviewUserMessage(args.plan, args.project_context, args.constraints, args.notes_to_reviewer, {
103
+ const userMessage = formatPlanReviewUserMessage(planText, args.project_context, args.constraints, args.notes_to_reviewer, {
74
104
  workingDirectories: args.working_directories,
75
105
  linkedRoots: args.linked_roots,
76
106
  changedFiles: args.changed_files,
@@ -91,6 +121,7 @@ export function registerPlanReviewTool(server) {
91
121
  outputSchema: PlanReviewOutputSchema,
92
122
  workspaceScope: scope,
93
123
  previousReviewId: args.previous_review_id,
124
+ toolName: 'plan',
94
125
  reviewerConfig: args.reviewer_config,
95
126
  createFallback: (reason, usedTools) => ({
96
127
  verdict: 'REVISE',
@@ -152,6 +183,7 @@ export function registerPlanReviewTool(server) {
152
183
  gates_tripped: gates.tripped.length > 0 ? gates.tripped : null,
153
184
  review_id: reviewId,
154
185
  ...iterMeta,
186
+ cost_warning: computeCostWarning(iterMeta, usage.estimated_cost_usd),
155
187
  token_usage: usage,
156
188
  };
157
189
  logUsage('plan_review', result.token_usage, {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@planningo/duul",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "DUUL — Dual-phase Upfront-plan & Unit-verify Loop. MCP server for LLM peer review of plans and code.",
5
5
  "type": "module",
6
6
  "main": "build/index.js",