tlc-claude-code 2.2.1 → 2.4.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/.claude/agents/builder.md +17 -0
- package/.claude/commands/tlc/audit.md +12 -0
- package/.claude/commands/tlc/autofix.md +31 -0
- package/.claude/commands/tlc/build.md +98 -24
- package/.claude/commands/tlc/coverage.md +31 -0
- package/.claude/commands/tlc/discuss.md +31 -0
- package/.claude/commands/tlc/docs.md +31 -0
- package/.claude/commands/tlc/edge-cases.md +31 -0
- package/.claude/commands/tlc/guard.md +9 -0
- package/.claude/commands/tlc/init.md +12 -1
- package/.claude/commands/tlc/plan.md +31 -0
- package/.claude/commands/tlc/quick.md +31 -0
- package/.claude/commands/tlc/review.md +50 -0
- package/.claude/hooks/tlc-session-init.sh +14 -3
- package/CODING-STANDARDS.md +217 -10
- package/bin/setup-autoupdate.js +316 -87
- package/bin/setup-autoupdate.test.js +454 -34
- package/package.json +1 -1
- package/scripts/project-docs.js +1 -1
- package/server/lib/careful-patterns.js +142 -0
- package/server/lib/careful-patterns.test.js +164 -0
- package/server/lib/cli-dispatcher.js +98 -0
- package/server/lib/cli-dispatcher.test.js +249 -0
- package/server/lib/command-router.js +171 -0
- package/server/lib/command-router.test.js +336 -0
- package/server/lib/field-report.js +92 -0
- package/server/lib/field-report.test.js +195 -0
- package/server/lib/orchestration/worktree-manager.js +133 -0
- package/server/lib/orchestration/worktree-manager.test.js +198 -0
- package/server/lib/overdrive-command.js +31 -9
- package/server/lib/overdrive-command.test.js +25 -26
- package/server/lib/prompt-packager.js +98 -0
- package/server/lib/prompt-packager.test.js +185 -0
- package/server/lib/review-fixer.js +107 -0
- package/server/lib/review-fixer.test.js +152 -0
- package/server/lib/routing-command.js +159 -0
- package/server/lib/routing-command.test.js +290 -0
- package/server/lib/scope-checker.js +127 -0
- package/server/lib/scope-checker.test.js +175 -0
- package/server/lib/skill-validator.js +165 -0
- package/server/lib/skill-validator.test.js +289 -0
- package/server/lib/standards/standards-injector.js +6 -0
- package/server/lib/task-router-config.js +142 -0
- package/server/lib/task-router-config.test.js +428 -0
- package/server/lib/test-selector.js +127 -0
- package/server/lib/test-selector.test.js +172 -0
- package/server/setup.sh +271 -271
- package/server/templates/CLAUDE.md +6 -0
- package/server/templates/CODING-STANDARDS.md +356 -10
|
@@ -75,11 +75,11 @@ describe('overdrive-command', () => {
|
|
|
75
75
|
|
|
76
76
|
it('parses --model flag', () => {
|
|
77
77
|
expect(parseOverdriveArgs('--model opus').model).toBe('opus');
|
|
78
|
-
expect(parseOverdriveArgs('--model sonnet').model).toBe('sonnet');
|
|
79
|
-
expect(parseOverdriveArgs('--model haiku').model).toBe('haiku');
|
|
80
78
|
});
|
|
81
79
|
|
|
82
|
-
it('ignores
|
|
80
|
+
it('ignores non-opus model values', () => {
|
|
81
|
+
expect(parseOverdriveArgs('--model sonnet').model).toBeNull();
|
|
82
|
+
expect(parseOverdriveArgs('--model haiku').model).toBeNull();
|
|
83
83
|
expect(parseOverdriveArgs('--model gpt4').model).toBeNull();
|
|
84
84
|
});
|
|
85
85
|
|
|
@@ -89,12 +89,12 @@ describe('overdrive-command', () => {
|
|
|
89
89
|
});
|
|
90
90
|
|
|
91
91
|
it('parses multiple flags including new ones', () => {
|
|
92
|
-
const options = parseOverdriveArgs('5 --agents 2 --mode test --model
|
|
92
|
+
const options = parseOverdriveArgs('5 --agents 2 --mode test --model opus --max-turns 25 --dry-run');
|
|
93
93
|
|
|
94
94
|
expect(options.phase).toBe(5);
|
|
95
95
|
expect(options.agents).toBe(2);
|
|
96
96
|
expect(options.mode).toBe('test');
|
|
97
|
-
expect(options.model).toBe('
|
|
97
|
+
expect(options.model).toBe('opus');
|
|
98
98
|
expect(options.maxTurns).toBe(25);
|
|
99
99
|
expect(options.dryRun).toBe(true);
|
|
100
100
|
});
|
|
@@ -152,7 +152,7 @@ describe('overdrive-command', () => {
|
|
|
152
152
|
expect(prompts[0].prompt).toContain('Fix any failing tests');
|
|
153
153
|
});
|
|
154
154
|
|
|
155
|
-
it('assigns
|
|
155
|
+
it('assigns opus for all task complexities', () => {
|
|
156
156
|
const tasks = [
|
|
157
157
|
{ id: 1, title: 'Refactor authentication system' },
|
|
158
158
|
{ id: 2, title: 'Add helper function' },
|
|
@@ -163,9 +163,9 @@ describe('overdrive-command', () => {
|
|
|
163
163
|
mode: 'build', projectDir: '/p', phase: 1,
|
|
164
164
|
});
|
|
165
165
|
|
|
166
|
-
expect(prompts[0].model).toBe('opus');
|
|
167
|
-
expect(prompts[1].model).toBe('
|
|
168
|
-
expect(prompts[2].model).toBe('
|
|
166
|
+
expect(prompts[0].model).toBe('opus');
|
|
167
|
+
expect(prompts[1].model).toBe('opus');
|
|
168
|
+
expect(prompts[2].model).toBe('opus');
|
|
169
169
|
});
|
|
170
170
|
|
|
171
171
|
it('respects model override', () => {
|
|
@@ -176,11 +176,11 @@ describe('overdrive-command', () => {
|
|
|
176
176
|
|
|
177
177
|
const prompts = generateAgentPrompts(tasks, {
|
|
178
178
|
mode: 'build', projectDir: '/p', phase: 1,
|
|
179
|
-
model: '
|
|
179
|
+
model: 'opus',
|
|
180
180
|
});
|
|
181
181
|
|
|
182
|
-
expect(prompts[0].model).toBe('
|
|
183
|
-
expect(prompts[1].model).toBe('
|
|
182
|
+
expect(prompts[0].model).toBe('opus');
|
|
183
|
+
expect(prompts[1].model).toBe('opus');
|
|
184
184
|
});
|
|
185
185
|
|
|
186
186
|
it('respects maxTurns override', () => {
|
|
@@ -286,8 +286,8 @@ describe('overdrive-command', () => {
|
|
|
286
286
|
describe('generateTaskCalls', () => {
|
|
287
287
|
it('generates task tool calls with model and max_turns', () => {
|
|
288
288
|
const prompts = [
|
|
289
|
-
{ taskId: 1, taskTitle: 'Test', prompt: 'Do task 1', agentType: 'general-purpose', model: '
|
|
290
|
-
{ taskId: 2, taskTitle: 'Test 2', prompt: 'Do task 2', agentType: 'general-purpose', model: '
|
|
289
|
+
{ taskId: 1, taskTitle: 'Test', prompt: 'Do task 1', agentType: 'general-purpose', model: 'opus', maxTurns: 50 },
|
|
290
|
+
{ taskId: 2, taskTitle: 'Test 2', prompt: 'Do task 2', agentType: 'general-purpose', model: 'opus', maxTurns: 30 },
|
|
291
291
|
];
|
|
292
292
|
|
|
293
293
|
const calls = generateTaskCalls(prompts);
|
|
@@ -297,9 +297,9 @@ describe('overdrive-command', () => {
|
|
|
297
297
|
expect(calls[0].params.description).toContain('Agent 1');
|
|
298
298
|
expect(calls[0].params.run_in_background).toBe(true);
|
|
299
299
|
expect(calls[0].params.subagent_type).toBe('general-purpose');
|
|
300
|
-
expect(calls[0].params.model).toBe('
|
|
300
|
+
expect(calls[0].params.model).toBe('opus');
|
|
301
301
|
expect(calls[0].params.max_turns).toBe(50);
|
|
302
|
-
expect(calls[1].params.model).toBe('
|
|
302
|
+
expect(calls[1].params.model).toBe('opus');
|
|
303
303
|
expect(calls[1].params.max_turns).toBe(30);
|
|
304
304
|
});
|
|
305
305
|
});
|
|
@@ -497,16 +497,16 @@ Blocked by Task 1
|
|
|
497
497
|
expect(getModelForTask({ title: 'Refactor auth' })).toBe('opus');
|
|
498
498
|
});
|
|
499
499
|
|
|
500
|
-
it('returns
|
|
501
|
-
expect(getModelForTask({ title: 'Add endpoint' })).toBe('
|
|
500
|
+
it('returns opus for standard tasks', () => {
|
|
501
|
+
expect(getModelForTask({ title: 'Add endpoint' })).toBe('opus');
|
|
502
502
|
});
|
|
503
503
|
|
|
504
|
-
it('returns
|
|
505
|
-
expect(getModelForTask({ title: 'Create enum' })).toBe('
|
|
504
|
+
it('returns opus for light tasks', () => {
|
|
505
|
+
expect(getModelForTask({ title: 'Create enum' })).toBe('opus');
|
|
506
506
|
});
|
|
507
507
|
|
|
508
508
|
it('respects model override', () => {
|
|
509
|
-
expect(getModelForTask({ title: 'Refactor auth' }, '
|
|
509
|
+
expect(getModelForTask({ title: 'Refactor auth' }, 'opus')).toBe('opus');
|
|
510
510
|
expect(getModelForTask({ title: 'Create enum' }, 'opus')).toBe('opus');
|
|
511
511
|
});
|
|
512
512
|
});
|
|
@@ -533,10 +533,10 @@ Blocked by Task 1
|
|
|
533
533
|
expect(AGENT_TYPES.PLAN).toBe('Plan');
|
|
534
534
|
});
|
|
535
535
|
|
|
536
|
-
it('exports valid model tiers', () => {
|
|
536
|
+
it('exports valid model tiers (all opus)', () => {
|
|
537
537
|
expect(MODEL_TIERS.HEAVY).toBe('opus');
|
|
538
|
-
expect(MODEL_TIERS.STANDARD).toBe('
|
|
539
|
-
expect(MODEL_TIERS.LIGHT).toBe('
|
|
538
|
+
expect(MODEL_TIERS.STANDARD).toBe('opus');
|
|
539
|
+
expect(MODEL_TIERS.LIGHT).toBe('opus');
|
|
540
540
|
});
|
|
541
541
|
|
|
542
542
|
it('exports default max turns', () => {
|
|
@@ -562,8 +562,7 @@ Blocked by Task 1
|
|
|
562
562
|
|
|
563
563
|
expect(output).toContain('Opus 4.6');
|
|
564
564
|
expect(output).toContain('[opus]');
|
|
565
|
-
expect(output).toContain('
|
|
566
|
-
expect(output).toContain('Model selection per task complexity');
|
|
565
|
+
expect(output).toContain('All agents use opus');
|
|
567
566
|
expect(output).toContain('Agent resumption');
|
|
568
567
|
expect(output).toContain('TaskOutput');
|
|
569
568
|
expect(output).toContain('TaskStop');
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Prompt Packager Module
|
|
3
|
+
* Serializes project context into a single prompt string for CLI dispatch to external LLMs.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const DEFAULT_TOKEN_BUDGET = 100000;
|
|
7
|
+
const TRUNCATION_MARKER = '...';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Format a source file as a labeled block for inclusion in the prompt.
|
|
11
|
+
* @param {string} filePath - Relative path to the file
|
|
12
|
+
* @param {string} content - File contents
|
|
13
|
+
* @returns {string} Formatted block with path header
|
|
14
|
+
*/
|
|
15
|
+
function formatFileBlock(filePath, content) {
|
|
16
|
+
return `--- ${filePath} ---\n${content}`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Truncate text to fit within a character budget.
|
|
21
|
+
* When truncation is needed, cuts at a character boundary and appends an ellipsis marker.
|
|
22
|
+
* @param {string} text - The text to truncate
|
|
23
|
+
* @param {number} budget - Maximum character count
|
|
24
|
+
* @returns {string} The text, truncated if necessary
|
|
25
|
+
*/
|
|
26
|
+
function truncateToBudget(text, budget) {
|
|
27
|
+
if (budget <= 0) return '';
|
|
28
|
+
if (text.length <= budget) return text;
|
|
29
|
+
const cutLen = budget - TRUNCATION_MARKER.length;
|
|
30
|
+
if (cutLen <= 0) return text.slice(0, budget);
|
|
31
|
+
return text.slice(0, cutLen) + TRUNCATION_MARKER;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Package project context and an agent prompt into a single string for external LLM dispatch.
|
|
36
|
+
* @param {object} opts
|
|
37
|
+
* @param {string} opts.agentPrompt - The command's instruction text (required)
|
|
38
|
+
* @param {string|null} [opts.projectDoc] - Contents of PROJECT.md
|
|
39
|
+
* @param {string|null} [opts.planDoc] - Contents of current phase PLAN.md
|
|
40
|
+
* @param {string|null} [opts.codingStandards] - Contents of CODING-STANDARDS.md
|
|
41
|
+
* @param {Array<{path: string, content: string}>|null} [opts.files] - Relevant source files
|
|
42
|
+
* @param {number} [opts.tokenBudget=100000] - Maximum characters in output
|
|
43
|
+
* @returns {string} Serialized prompt string
|
|
44
|
+
*/
|
|
45
|
+
function packagePrompt({
|
|
46
|
+
agentPrompt,
|
|
47
|
+
projectDoc = null,
|
|
48
|
+
planDoc = null,
|
|
49
|
+
codingStandards = null,
|
|
50
|
+
files = null,
|
|
51
|
+
tokenBudget = DEFAULT_TOKEN_BUDGET,
|
|
52
|
+
}) {
|
|
53
|
+
if (tokenBudget <= 0) return '';
|
|
54
|
+
|
|
55
|
+
// Pre-truncate large individual file contents to a per-file budget.
|
|
56
|
+
// Each file gets at most half the total budget to leave room for other sections.
|
|
57
|
+
const perFileBudget = Math.floor(tokenBudget / 2);
|
|
58
|
+
const sortedFiles =
|
|
59
|
+
files && files.length > 0
|
|
60
|
+
? [...files]
|
|
61
|
+
.sort((a, b) => a.path.localeCompare(b.path))
|
|
62
|
+
.map((f) => ({
|
|
63
|
+
path: f.path,
|
|
64
|
+
content: truncateToBudget(f.content, perFileBudget),
|
|
65
|
+
}))
|
|
66
|
+
: null;
|
|
67
|
+
|
|
68
|
+
// Task section goes FIRST so truncation of context never drops the instruction
|
|
69
|
+
const sections = [];
|
|
70
|
+
|
|
71
|
+
sections.push(`--- Task ---\n${agentPrompt}`);
|
|
72
|
+
|
|
73
|
+
sections.push('\nYou are working on the following project:');
|
|
74
|
+
|
|
75
|
+
if (projectDoc != null) {
|
|
76
|
+
sections.push(`\n--- PROJECT.md ---\n${projectDoc}`);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (planDoc != null) {
|
|
80
|
+
sections.push(`\n--- Current Phase Plan ---\n${planDoc}`);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (codingStandards != null) {
|
|
84
|
+
sections.push(`\n--- Coding Standards ---\n${codingStandards}`);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (sortedFiles && sortedFiles.length > 0) {
|
|
88
|
+
const fileBlocks = sortedFiles
|
|
89
|
+
.map((f) => formatFileBlock(f.path, f.content))
|
|
90
|
+
.join('\n\n');
|
|
91
|
+
sections.push(`\n--- Relevant Files ---\n\n${fileBlocks}`);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const full = sections.join('\n');
|
|
95
|
+
return truncateToBudget(full, tokenBudget);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
module.exports = { packagePrompt, truncateToBudget, formatFileBlock };
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
packagePrompt,
|
|
4
|
+
truncateToBudget,
|
|
5
|
+
formatFileBlock,
|
|
6
|
+
} from './prompt-packager.js';
|
|
7
|
+
|
|
8
|
+
describe('prompt-packager', () => {
|
|
9
|
+
describe('formatFileBlock', () => {
|
|
10
|
+
it('produces correct header format', () => {
|
|
11
|
+
const result = formatFileBlock('src/auth/login.ts', 'const x = 1;');
|
|
12
|
+
expect(result).toBe('--- src/auth/login.ts ---\nconst x = 1;');
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('handles empty content', () => {
|
|
16
|
+
const result = formatFileBlock('empty.js', '');
|
|
17
|
+
expect(result).toBe('--- empty.js ---\n');
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
describe('truncateToBudget', () => {
|
|
22
|
+
it('returns text unchanged when within budget', () => {
|
|
23
|
+
const text = 'Hello world';
|
|
24
|
+
expect(truncateToBudget(text, 100)).toBe(text);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('truncates at char boundary with ellipsis marker', () => {
|
|
28
|
+
const text = 'abcdefghij'; // 10 chars
|
|
29
|
+
const result = truncateToBudget(text, 8);
|
|
30
|
+
expect(result.length).toBeLessThanOrEqual(8);
|
|
31
|
+
expect(result).toContain('...');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('returns empty string for budget of 0', () => {
|
|
35
|
+
expect(truncateToBudget('anything', 0)).toBe('');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('handles text exactly at budget', () => {
|
|
39
|
+
const text = 'exact';
|
|
40
|
+
expect(truncateToBudget(text, 5)).toBe('exact');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('handles empty text', () => {
|
|
44
|
+
expect(truncateToBudget('', 100)).toBe('');
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe('packagePrompt', () => {
|
|
49
|
+
const fullInput = {
|
|
50
|
+
agentPrompt: 'Implement the login feature',
|
|
51
|
+
projectDoc: '# My Project\nA cool project.',
|
|
52
|
+
planDoc: '## Phase 1\n- [ ] Task 1',
|
|
53
|
+
codingStandards: '## Standards\nUse JSDoc.',
|
|
54
|
+
files: [
|
|
55
|
+
{ path: 'src/auth/login.ts', content: 'export function login() {}' },
|
|
56
|
+
{ path: 'tests/auth/login.test.ts', content: 'test("login", () => {})' },
|
|
57
|
+
],
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
it('packages all sections when everything provided', () => {
|
|
61
|
+
const result = packagePrompt(fullInput);
|
|
62
|
+
expect(result).toContain('You are working on the following project:');
|
|
63
|
+
expect(result).toContain('--- PROJECT.md ---');
|
|
64
|
+
expect(result).toContain('# My Project');
|
|
65
|
+
expect(result).toContain('--- Current Phase Plan ---');
|
|
66
|
+
expect(result).toContain('## Phase 1');
|
|
67
|
+
expect(result).toContain('--- Coding Standards ---');
|
|
68
|
+
expect(result).toContain('## Standards');
|
|
69
|
+
expect(result).toContain('--- Relevant Files ---');
|
|
70
|
+
expect(result).toContain('--- src/auth/login.ts ---');
|
|
71
|
+
expect(result).toContain('export function login() {}');
|
|
72
|
+
expect(result).toContain('--- tests/auth/login.test.ts ---');
|
|
73
|
+
expect(result).toContain('test("login", () => {})');
|
|
74
|
+
expect(result).toContain('--- Task ---');
|
|
75
|
+
expect(result).toContain('Implement the login feature');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('omits PROJECT.md section when null', () => {
|
|
79
|
+
const result = packagePrompt({ ...fullInput, projectDoc: null });
|
|
80
|
+
expect(result).not.toContain('--- PROJECT.md ---');
|
|
81
|
+
expect(result).toContain('--- Current Phase Plan ---');
|
|
82
|
+
expect(result).toContain('--- Task ---');
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('omits plan section when null', () => {
|
|
86
|
+
const result = packagePrompt({ ...fullInput, planDoc: null });
|
|
87
|
+
expect(result).not.toContain('--- Current Phase Plan ---');
|
|
88
|
+
expect(result).toContain('--- PROJECT.md ---');
|
|
89
|
+
expect(result).toContain('--- Task ---');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('omits coding standards when null', () => {
|
|
93
|
+
const result = packagePrompt({ ...fullInput, codingStandards: null });
|
|
94
|
+
expect(result).not.toContain('--- Coding Standards ---');
|
|
95
|
+
expect(result).toContain('--- PROJECT.md ---');
|
|
96
|
+
expect(result).toContain('--- Task ---');
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('omits files section when null', () => {
|
|
100
|
+
const result = packagePrompt({ ...fullInput, files: null });
|
|
101
|
+
expect(result).not.toContain('--- Relevant Files ---');
|
|
102
|
+
expect(result).toContain('--- PROJECT.md ---');
|
|
103
|
+
expect(result).toContain('--- Task ---');
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('omits files section when empty array', () => {
|
|
107
|
+
const result = packagePrompt({ ...fullInput, files: [] });
|
|
108
|
+
expect(result).not.toContain('--- Relevant Files ---');
|
|
109
|
+
expect(result).toContain('--- Task ---');
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('empty agentPrompt still produces valid output', () => {
|
|
113
|
+
const result = packagePrompt({ ...fullInput, agentPrompt: '' });
|
|
114
|
+
expect(result).toContain('--- Task ---');
|
|
115
|
+
expect(result).toContain('--- PROJECT.md ---');
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('files sorted by path for deterministic output', () => {
|
|
119
|
+
const result = packagePrompt({
|
|
120
|
+
agentPrompt: 'do it',
|
|
121
|
+
files: [
|
|
122
|
+
{ path: 'z/last.js', content: 'last' },
|
|
123
|
+
{ path: 'a/first.js', content: 'first' },
|
|
124
|
+
{ path: 'm/middle.js', content: 'middle' },
|
|
125
|
+
],
|
|
126
|
+
});
|
|
127
|
+
const aIdx = result.indexOf('--- a/first.js ---');
|
|
128
|
+
const mIdx = result.indexOf('--- m/middle.js ---');
|
|
129
|
+
const zIdx = result.indexOf('--- z/last.js ---');
|
|
130
|
+
expect(aIdx).toBeLessThan(mIdx);
|
|
131
|
+
expect(mIdx).toBeLessThan(zIdx);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('truncates to budget when total exceeds limit', () => {
|
|
135
|
+
const result = packagePrompt({
|
|
136
|
+
...fullInput,
|
|
137
|
+
tokenBudget: 50,
|
|
138
|
+
});
|
|
139
|
+
expect(result.length).toBeLessThanOrEqual(50);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('budget of 0 returns empty string', () => {
|
|
143
|
+
const result = packagePrompt({ ...fullInput, tokenBudget: 0 });
|
|
144
|
+
expect(result).toBe('');
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('large individual files truncated before total budget check', () => {
|
|
148
|
+
const largeContent = 'x'.repeat(200000);
|
|
149
|
+
const result = packagePrompt({
|
|
150
|
+
agentPrompt: 'task',
|
|
151
|
+
files: [{ path: 'big.js', content: largeContent }],
|
|
152
|
+
tokenBudget: 1000,
|
|
153
|
+
});
|
|
154
|
+
expect(result.length).toBeLessThanOrEqual(1000);
|
|
155
|
+
// The file should be present but truncated
|
|
156
|
+
expect(result).toContain('--- big.js ---');
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('places task section before context so truncation never drops the instruction', () => {
|
|
160
|
+
const result = packagePrompt(fullInput);
|
|
161
|
+
const taskIdx = result.indexOf('--- Task ---');
|
|
162
|
+
const projectIdx = result.indexOf('--- PROJECT.md ---');
|
|
163
|
+
expect(taskIdx).toBeLessThan(projectIdx);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('uses default tokenBudget of 100000 when not specified', () => {
|
|
167
|
+
const largeProject = 'x'.repeat(200000);
|
|
168
|
+
const result = packagePrompt({
|
|
169
|
+
agentPrompt: 'task',
|
|
170
|
+
projectDoc: largeProject,
|
|
171
|
+
});
|
|
172
|
+
expect(result.length).toBeLessThanOrEqual(100000);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('handles minimal input with only agentPrompt', () => {
|
|
176
|
+
const result = packagePrompt({ agentPrompt: 'just do it' });
|
|
177
|
+
expect(result).toContain('--- Task ---');
|
|
178
|
+
expect(result).toContain('just do it');
|
|
179
|
+
expect(result).not.toContain('--- PROJECT.md ---');
|
|
180
|
+
expect(result).not.toContain('--- Current Phase Plan ---');
|
|
181
|
+
expect(result).not.toContain('--- Coding Standards ---');
|
|
182
|
+
expect(result).not.toContain('--- Relevant Files ---');
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
});
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Review Fixer Module
|
|
3
|
+
* Classifies review findings as AUTO-FIX (apply immediately) or ASK (batch for user approval).
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Finding types that can be automatically fixed without user input.
|
|
8
|
+
* These are mechanical, low-risk corrections.
|
|
9
|
+
*/
|
|
10
|
+
const AUTO_FIX_TYPES = {
|
|
11
|
+
any_type: 'Type annotation can be inferred or added mechanically',
|
|
12
|
+
missing_return_type: 'Return type can be inferred from implementation',
|
|
13
|
+
console_log: 'Debug logging can be safely removed',
|
|
14
|
+
missing_test_skeleton: 'Test skeleton can be generated from function signature',
|
|
15
|
+
hardcoded_url: 'URL can be extracted to environment variable',
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Finding types that require user decision.
|
|
20
|
+
* These involve judgment, security, or architectural choices.
|
|
21
|
+
*/
|
|
22
|
+
const ASK_TYPES = {
|
|
23
|
+
ownership_missing: 'Ownership assignment requires team decision',
|
|
24
|
+
secrets_in_response: 'Secret handling requires security review',
|
|
25
|
+
architectural_decision: 'Architecture changes need explicit approval',
|
|
26
|
+
ambiguous_security: 'Security implications need human assessment',
|
|
27
|
+
scope_creep: 'Scope change requires product owner approval',
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Classify a review finding into auto-fix or ask category
|
|
32
|
+
* @param {string} type - The finding type identifier
|
|
33
|
+
* @returns {{ action: 'auto-fix' | 'ask', reason: string }} Classification result
|
|
34
|
+
*/
|
|
35
|
+
function classifyFinding(type) {
|
|
36
|
+
if (!type || typeof type !== 'string') {
|
|
37
|
+
return { action: 'ask', reason: 'Invalid or missing finding type' };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (AUTO_FIX_TYPES[type]) {
|
|
41
|
+
return { action: 'auto-fix', reason: AUTO_FIX_TYPES[type] };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (ASK_TYPES[type]) {
|
|
45
|
+
return { action: 'ask', reason: ASK_TYPES[type] };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return { action: 'ask', reason: `Finding type "${type}" is unknown — defaulting to ask for safety` };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Split an array of findings into auto-fix and ask buckets
|
|
53
|
+
* @param {Array<{ type: string }>} findings - Array of finding objects with a type property
|
|
54
|
+
* @returns {{ autoFix: Array, ask: Array }} Separated findings
|
|
55
|
+
*/
|
|
56
|
+
function splitFindings(findings) {
|
|
57
|
+
if (!Array.isArray(findings)) {
|
|
58
|
+
return { autoFix: [], ask: [] };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const autoFix = [];
|
|
62
|
+
const ask = [];
|
|
63
|
+
|
|
64
|
+
for (const finding of findings) {
|
|
65
|
+
const classification = classifyFinding(finding.type);
|
|
66
|
+
if (classification.action === 'auto-fix') {
|
|
67
|
+
autoFix.push(finding);
|
|
68
|
+
} else {
|
|
69
|
+
ask.push(finding);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return { autoFix, ask };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Format a markdown summary report of fix results
|
|
78
|
+
* @param {number} autoFixed - Count of automatically fixed findings
|
|
79
|
+
* @param {number} needsInput - Count of findings needing user input
|
|
80
|
+
* @returns {string} Formatted markdown report
|
|
81
|
+
*/
|
|
82
|
+
function formatFixReport(autoFixed, needsInput) {
|
|
83
|
+
const total = autoFixed + needsInput;
|
|
84
|
+
|
|
85
|
+
const lines = [
|
|
86
|
+
`## Review Fix Report`,
|
|
87
|
+
'',
|
|
88
|
+
`**${total}** findings processed.`,
|
|
89
|
+
'',
|
|
90
|
+
`### Auto-Fixed: ${autoFixed}`,
|
|
91
|
+
'',
|
|
92
|
+
autoFixed > 0
|
|
93
|
+
? `${autoFixed} finding(s) were automatically resolved.`
|
|
94
|
+
: 'No findings were auto-fixed.',
|
|
95
|
+
'',
|
|
96
|
+
`### Needs Input: ${needsInput}`,
|
|
97
|
+
'',
|
|
98
|
+
needsInput > 0
|
|
99
|
+
? `${needsInput} finding(s) require your review and decision.`
|
|
100
|
+
: 'No findings need your input.',
|
|
101
|
+
];
|
|
102
|
+
|
|
103
|
+
return lines.join('\n');
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
module.exports = { classifyFinding, splitFindings, formatFixReport };
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
classifyFinding,
|
|
4
|
+
splitFindings,
|
|
5
|
+
formatFixReport,
|
|
6
|
+
} from './review-fixer.js';
|
|
7
|
+
|
|
8
|
+
describe('review-fixer', () => {
|
|
9
|
+
describe('classifyFinding', () => {
|
|
10
|
+
it('classifies any_type as auto-fix', () => {
|
|
11
|
+
const result = classifyFinding('any_type');
|
|
12
|
+
expect(result.action).toBe('auto-fix');
|
|
13
|
+
expect(result.reason).toBeTypeOf('string');
|
|
14
|
+
expect(result.reason.length).toBeGreaterThan(0);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('classifies ownership_missing as ask', () => {
|
|
18
|
+
const result = classifyFinding('ownership_missing');
|
|
19
|
+
expect(result.action).toBe('ask');
|
|
20
|
+
expect(result.reason).toBeTypeOf('string');
|
|
21
|
+
expect(result.reason.length).toBeGreaterThan(0);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('classifies console_log as auto-fix', () => {
|
|
25
|
+
const result = classifyFinding('console_log');
|
|
26
|
+
expect(result.action).toBe('auto-fix');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('classifies missing_return_type as auto-fix', () => {
|
|
30
|
+
const result = classifyFinding('missing_return_type');
|
|
31
|
+
expect(result.action).toBe('auto-fix');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('classifies missing_test_skeleton as auto-fix', () => {
|
|
35
|
+
const result = classifyFinding('missing_test_skeleton');
|
|
36
|
+
expect(result.action).toBe('auto-fix');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('classifies hardcoded_url as auto-fix', () => {
|
|
40
|
+
const result = classifyFinding('hardcoded_url');
|
|
41
|
+
expect(result.action).toBe('auto-fix');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('classifies secrets_in_response as ask', () => {
|
|
45
|
+
const result = classifyFinding('secrets_in_response');
|
|
46
|
+
expect(result.action).toBe('ask');
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('classifies architectural_decision as ask', () => {
|
|
50
|
+
const result = classifyFinding('architectural_decision');
|
|
51
|
+
expect(result.action).toBe('ask');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('classifies ambiguous_security as ask', () => {
|
|
55
|
+
const result = classifyFinding('ambiguous_security');
|
|
56
|
+
expect(result.action).toBe('ask');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('classifies scope_creep as ask', () => {
|
|
60
|
+
const result = classifyFinding('scope_creep');
|
|
61
|
+
expect(result.action).toBe('ask');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('defaults unknown types to ask (safe default)', () => {
|
|
65
|
+
const result = classifyFinding('unknown_type');
|
|
66
|
+
expect(result.action).toBe('ask');
|
|
67
|
+
expect(result.reason).toContain('unknown');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('defaults another unrecognized type to ask', () => {
|
|
71
|
+
const result = classifyFinding('some_random_finding');
|
|
72
|
+
expect(result.action).toBe('ask');
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
describe('splitFindings', () => {
|
|
77
|
+
it('correctly separates auto-fix from ask findings', () => {
|
|
78
|
+
const findings = [
|
|
79
|
+
{ type: 'any_type', file: 'a.js', line: 1 },
|
|
80
|
+
{ type: 'ownership_missing', file: 'b.js', line: 5 },
|
|
81
|
+
{ type: 'console_log', file: 'c.js', line: 10 },
|
|
82
|
+
{ type: 'architectural_decision', file: 'd.js', line: 3 },
|
|
83
|
+
{ type: 'hardcoded_url', file: 'e.js', line: 7 },
|
|
84
|
+
];
|
|
85
|
+
|
|
86
|
+
const result = splitFindings(findings);
|
|
87
|
+
|
|
88
|
+
expect(result.autoFix).toHaveLength(3);
|
|
89
|
+
expect(result.ask).toHaveLength(2);
|
|
90
|
+
|
|
91
|
+
expect(result.autoFix.map(f => f.type)).toContain('any_type');
|
|
92
|
+
expect(result.autoFix.map(f => f.type)).toContain('console_log');
|
|
93
|
+
expect(result.autoFix.map(f => f.type)).toContain('hardcoded_url');
|
|
94
|
+
|
|
95
|
+
expect(result.ask.map(f => f.type)).toContain('ownership_missing');
|
|
96
|
+
expect(result.ask.map(f => f.type)).toContain('architectural_decision');
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('returns empty arrays for empty input', () => {
|
|
100
|
+
const result = splitFindings([]);
|
|
101
|
+
expect(result.autoFix).toEqual([]);
|
|
102
|
+
expect(result.ask).toEqual([]);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('handles all auto-fix findings', () => {
|
|
106
|
+
const findings = [
|
|
107
|
+
{ type: 'any_type', file: 'a.js', line: 1 },
|
|
108
|
+
{ type: 'console_log', file: 'b.js', line: 2 },
|
|
109
|
+
];
|
|
110
|
+
const result = splitFindings(findings);
|
|
111
|
+
expect(result.autoFix).toHaveLength(2);
|
|
112
|
+
expect(result.ask).toHaveLength(0);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('handles all ask findings', () => {
|
|
116
|
+
const findings = [
|
|
117
|
+
{ type: 'ownership_missing', file: 'a.js', line: 1 },
|
|
118
|
+
{ type: 'scope_creep', file: 'b.js', line: 2 },
|
|
119
|
+
];
|
|
120
|
+
const result = splitFindings(findings);
|
|
121
|
+
expect(result.autoFix).toHaveLength(0);
|
|
122
|
+
expect(result.ask).toHaveLength(2);
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
describe('formatFixReport', () => {
|
|
127
|
+
it('includes counts and section headers', () => {
|
|
128
|
+
const report = formatFixReport(3, 2);
|
|
129
|
+
expect(report).toContain('3');
|
|
130
|
+
expect(report).toContain('2');
|
|
131
|
+
expect(report).toContain('Auto-Fix');
|
|
132
|
+
expect(report).toContain('Needs Input');
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('handles zero auto-fixed', () => {
|
|
136
|
+
const report = formatFixReport(0, 4);
|
|
137
|
+
expect(report).toContain('0');
|
|
138
|
+
expect(report).toContain('4');
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('handles zero needing input', () => {
|
|
142
|
+
const report = formatFixReport(5, 0);
|
|
143
|
+
expect(report).toContain('5');
|
|
144
|
+
expect(report).toContain('0');
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('returns valid markdown', () => {
|
|
148
|
+
const report = formatFixReport(2, 1);
|
|
149
|
+
expect(report).toContain('#');
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
});
|