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
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Routing Command - Config management for per-command model routing
|
|
3
|
+
*
|
|
4
|
+
* Shows effective routing config, installed providers, and saves personal config.
|
|
5
|
+
* Used by the `/tlc:llm` command.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const path = require('path');
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Show effective routing table after precedence merge.
|
|
12
|
+
* @param {Object} deps - Injected dependencies
|
|
13
|
+
* @param {Function} deps.resolveRouting - from task-router-config
|
|
14
|
+
* @param {string[]} deps.commands - list of routable commands
|
|
15
|
+
* @returns {Array<{ command: string, models: string[], strategy: string, source: string }>}
|
|
16
|
+
*/
|
|
17
|
+
function showRouting({ resolveRouting, commands, projectDir, homeDir }) {
|
|
18
|
+
return commands.map((cmd) => {
|
|
19
|
+
const resolved = resolveRouting({ command: cmd, projectDir, homeDir });
|
|
20
|
+
return {
|
|
21
|
+
command: cmd,
|
|
22
|
+
models: resolved.models,
|
|
23
|
+
strategy: resolved.strategy,
|
|
24
|
+
source: resolved.source,
|
|
25
|
+
};
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Show installed CLI providers with availability.
|
|
31
|
+
* @param {Object} deps - Injected dependencies
|
|
32
|
+
* @param {Function} deps.detectCLI - from cli-detector
|
|
33
|
+
* @param {string[]} deps.providerNames - provider names to check
|
|
34
|
+
* @returns {Promise<Array<{ name: string, found: boolean, path: string|null, version: string|null }>>}
|
|
35
|
+
*/
|
|
36
|
+
async function showProviders({ detectCLI, providerNames }) {
|
|
37
|
+
const results = await Promise.all(
|
|
38
|
+
providerNames.map(async (name) => {
|
|
39
|
+
const detection = await detectCLI(name);
|
|
40
|
+
return {
|
|
41
|
+
name,
|
|
42
|
+
found: detection.found,
|
|
43
|
+
path: detection.path,
|
|
44
|
+
version: detection.version,
|
|
45
|
+
};
|
|
46
|
+
}),
|
|
47
|
+
);
|
|
48
|
+
return results;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Write task_routing config to personal config file.
|
|
53
|
+
* @param {Object} opts
|
|
54
|
+
* @param {Object} opts.routing - The task_routing object to save
|
|
55
|
+
* @param {string} opts.homeDir - Home directory
|
|
56
|
+
* @param {Object} opts.fs - Injected fs module
|
|
57
|
+
* @returns {{ saved: boolean, path: string }}
|
|
58
|
+
*/
|
|
59
|
+
function savePersonalRouting({ routing, homeDir, fs }) {
|
|
60
|
+
const dirPath = path.join(homeDir, '.tlc');
|
|
61
|
+
const configPath = path.join(dirPath, 'config.json');
|
|
62
|
+
|
|
63
|
+
// Ensure directory exists
|
|
64
|
+
if (!fs.existsSync(dirPath)) {
|
|
65
|
+
fs.mkdirSync(dirPath, { recursive: true });
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Load existing config or start fresh
|
|
69
|
+
let existing = {};
|
|
70
|
+
try {
|
|
71
|
+
const content = fs.readFileSync(configPath, 'utf-8');
|
|
72
|
+
existing = JSON.parse(content);
|
|
73
|
+
} catch {
|
|
74
|
+
// No existing config, start fresh
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Merge: replace task_routing, keep everything else
|
|
78
|
+
existing.task_routing = routing;
|
|
79
|
+
|
|
80
|
+
fs.writeFileSync(configPath, JSON.stringify(existing, null, 2));
|
|
81
|
+
|
|
82
|
+
return { saved: true, path: configPath };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Format routing table for display.
|
|
87
|
+
* @param {Array} routingTable - Output from showRouting
|
|
88
|
+
* @returns {string} Formatted text table
|
|
89
|
+
*/
|
|
90
|
+
function formatRoutingTable(routingTable) {
|
|
91
|
+
if (!routingTable || routingTable.length === 0) {
|
|
92
|
+
return 'No routing entries configured.';
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Calculate column widths
|
|
96
|
+
const headers = { command: 'Command', models: 'Models', strategy: 'Strategy', source: 'Source' };
|
|
97
|
+
let cmdW = headers.command.length;
|
|
98
|
+
let modW = headers.models.length;
|
|
99
|
+
let strW = headers.strategy.length;
|
|
100
|
+
let srcW = headers.source.length;
|
|
101
|
+
|
|
102
|
+
const rows = routingTable.map((entry) => {
|
|
103
|
+
const modelsStr = entry.models.join(' + ');
|
|
104
|
+
cmdW = Math.max(cmdW, entry.command.length);
|
|
105
|
+
modW = Math.max(modW, modelsStr.length);
|
|
106
|
+
strW = Math.max(strW, entry.strategy.length);
|
|
107
|
+
srcW = Math.max(srcW, entry.source.length);
|
|
108
|
+
return { command: entry.command, models: modelsStr, strategy: entry.strategy, source: entry.source };
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
const pad = (str, len) => str.padEnd(len);
|
|
112
|
+
const lines = [];
|
|
113
|
+
|
|
114
|
+
// Header
|
|
115
|
+
lines.push(
|
|
116
|
+
`${pad(headers.command, cmdW)} ${pad(headers.models, modW)} ${pad(headers.strategy, strW)} ${pad(headers.source, srcW)}`,
|
|
117
|
+
);
|
|
118
|
+
// Separator
|
|
119
|
+
lines.push(
|
|
120
|
+
`${'-'.repeat(cmdW)} ${'-'.repeat(modW)} ${'-'.repeat(strW)} ${'-'.repeat(srcW)}`,
|
|
121
|
+
);
|
|
122
|
+
// Rows
|
|
123
|
+
for (const row of rows) {
|
|
124
|
+
lines.push(
|
|
125
|
+
`${pad(row.command, cmdW)} ${pad(row.models, modW)} ${pad(row.strategy, strW)} ${pad(row.source, srcW)}`,
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return lines.join('\n');
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Format provider list for display.
|
|
134
|
+
* @param {Array} providers - Output from showProviders
|
|
135
|
+
* @returns {string} Formatted text list
|
|
136
|
+
*/
|
|
137
|
+
function formatProviderList(providers) {
|
|
138
|
+
if (!providers || providers.length === 0) {
|
|
139
|
+
return 'No providers configured.';
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const lines = providers.map((p) => {
|
|
143
|
+
const icon = p.found ? '✓' : '✗';
|
|
144
|
+
const status = p.found ? 'installed' : 'not found';
|
|
145
|
+
const version = p.version ? ` (${p.version})` : '';
|
|
146
|
+
const loc = p.path ? ` — ${p.path}` : '';
|
|
147
|
+
return ` ${icon} ${p.name}: ${status}${version}${loc}`;
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
return lines.join('\n');
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
module.exports = {
|
|
154
|
+
showRouting,
|
|
155
|
+
showProviders,
|
|
156
|
+
savePersonalRouting,
|
|
157
|
+
formatRoutingTable,
|
|
158
|
+
formatProviderList,
|
|
159
|
+
};
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
showRouting,
|
|
4
|
+
showProviders,
|
|
5
|
+
savePersonalRouting,
|
|
6
|
+
formatRoutingTable,
|
|
7
|
+
formatProviderList,
|
|
8
|
+
} from './routing-command.js';
|
|
9
|
+
|
|
10
|
+
describe('routing-command', () => {
|
|
11
|
+
// ── showRouting ───────────────────────────────────────────────────
|
|
12
|
+
describe('showRouting', () => {
|
|
13
|
+
it('returns routing for all routable commands', () => {
|
|
14
|
+
const commands = ['build', 'review', 'plan'];
|
|
15
|
+
const resolveRouting = vi.fn(() => ({
|
|
16
|
+
models: ['claude'],
|
|
17
|
+
strategy: 'single',
|
|
18
|
+
source: 'shipped-defaults',
|
|
19
|
+
}));
|
|
20
|
+
|
|
21
|
+
const result = showRouting({ resolveRouting, commands });
|
|
22
|
+
|
|
23
|
+
expect(result).toHaveLength(3);
|
|
24
|
+
expect(resolveRouting).toHaveBeenCalledTimes(3);
|
|
25
|
+
// Verify it passes an options object with command field
|
|
26
|
+
expect(resolveRouting).toHaveBeenCalledWith(expect.objectContaining({ command: 'build' }));
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('each entry has command, models, strategy, source', () => {
|
|
30
|
+
const commands = ['build'];
|
|
31
|
+
const resolveRouting = vi.fn(() => ({
|
|
32
|
+
models: ['claude', 'codex'],
|
|
33
|
+
strategy: 'parallel',
|
|
34
|
+
source: 'personal-config',
|
|
35
|
+
}));
|
|
36
|
+
|
|
37
|
+
const result = showRouting({ resolveRouting, commands });
|
|
38
|
+
|
|
39
|
+
expect(result[0]).toEqual({
|
|
40
|
+
command: 'build',
|
|
41
|
+
models: ['claude', 'codex'],
|
|
42
|
+
strategy: 'parallel',
|
|
43
|
+
source: 'personal-config',
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('uses resolveRouting for each command', () => {
|
|
48
|
+
const commands = ['build', 'review'];
|
|
49
|
+
const resolveRouting = vi.fn((opts) => {
|
|
50
|
+
if (opts.command === 'build') {
|
|
51
|
+
return { models: ['codex'], strategy: 'single', source: 'personal-config' };
|
|
52
|
+
}
|
|
53
|
+
return { models: ['claude'], strategy: 'single', source: 'shipped-defaults' };
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const result = showRouting({ resolveRouting, commands });
|
|
57
|
+
|
|
58
|
+
expect(result[0]).toEqual({
|
|
59
|
+
command: 'build',
|
|
60
|
+
models: ['codex'],
|
|
61
|
+
strategy: 'single',
|
|
62
|
+
source: 'personal-config',
|
|
63
|
+
});
|
|
64
|
+
expect(result[1]).toEqual({
|
|
65
|
+
command: 'review',
|
|
66
|
+
models: ['claude'],
|
|
67
|
+
strategy: 'single',
|
|
68
|
+
source: 'shipped-defaults',
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
// ── showProviders ─────────────────────────────────────────────────
|
|
74
|
+
describe('showProviders', () => {
|
|
75
|
+
it('returns status for each provider name', async () => {
|
|
76
|
+
const detectCLI = vi.fn(async () => ({
|
|
77
|
+
found: true,
|
|
78
|
+
path: '/usr/bin/claude',
|
|
79
|
+
version: '1.0.0',
|
|
80
|
+
}));
|
|
81
|
+
|
|
82
|
+
const result = await showProviders({
|
|
83
|
+
detectCLI,
|
|
84
|
+
providerNames: ['claude', 'codex'],
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
expect(result).toHaveLength(2);
|
|
88
|
+
expect(detectCLI).toHaveBeenCalledTimes(2);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('includes found, path, version fields', async () => {
|
|
92
|
+
const detectCLI = vi.fn(async () => ({
|
|
93
|
+
found: true,
|
|
94
|
+
path: '/usr/local/bin/claude',
|
|
95
|
+
version: '2.1.0',
|
|
96
|
+
}));
|
|
97
|
+
|
|
98
|
+
const result = await showProviders({
|
|
99
|
+
detectCLI,
|
|
100
|
+
providerNames: ['claude'],
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
expect(result[0]).toEqual({
|
|
104
|
+
name: 'claude',
|
|
105
|
+
found: true,
|
|
106
|
+
path: '/usr/local/bin/claude',
|
|
107
|
+
version: '2.1.0',
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('handles provider not found', async () => {
|
|
112
|
+
const detectCLI = vi.fn(async () => ({
|
|
113
|
+
found: false,
|
|
114
|
+
path: null,
|
|
115
|
+
version: null,
|
|
116
|
+
}));
|
|
117
|
+
|
|
118
|
+
const result = await showProviders({
|
|
119
|
+
detectCLI,
|
|
120
|
+
providerNames: ['gemini'],
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
expect(result[0]).toEqual({
|
|
124
|
+
name: 'gemini',
|
|
125
|
+
found: false,
|
|
126
|
+
path: null,
|
|
127
|
+
version: null,
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
// ── savePersonalRouting ───────────────────────────────────────────
|
|
133
|
+
describe('savePersonalRouting', () => {
|
|
134
|
+
it('writes config to ~/.tlc/config.json', () => {
|
|
135
|
+
const routing = { build: { models: ['codex'], strategy: 'single' } };
|
|
136
|
+
const written = {};
|
|
137
|
+
const fs = {
|
|
138
|
+
existsSync: vi.fn(() => true),
|
|
139
|
+
readFileSync: vi.fn(() => '{}'),
|
|
140
|
+
mkdirSync: vi.fn(),
|
|
141
|
+
writeFileSync: vi.fn((path, data) => { written.path = path; written.data = data; }),
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
const result = savePersonalRouting({ routing, homeDir: '/home/user', fs });
|
|
145
|
+
|
|
146
|
+
expect(result.saved).toBe(true);
|
|
147
|
+
expect(result.path).toBe('/home/user/.tlc/config.json');
|
|
148
|
+
expect(written.path).toBe('/home/user/.tlc/config.json');
|
|
149
|
+
const parsed = JSON.parse(written.data);
|
|
150
|
+
expect(parsed.task_routing).toEqual(routing);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('creates directory if missing', () => {
|
|
154
|
+
const routing = { build: { models: ['claude'], strategy: 'single' } };
|
|
155
|
+
const fs = {
|
|
156
|
+
existsSync: vi.fn(() => false),
|
|
157
|
+
readFileSync: vi.fn(() => { throw new Error('ENOENT'); }),
|
|
158
|
+
mkdirSync: vi.fn(),
|
|
159
|
+
writeFileSync: vi.fn(),
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
savePersonalRouting({ routing, homeDir: '/home/user', fs });
|
|
163
|
+
|
|
164
|
+
expect(fs.mkdirSync).toHaveBeenCalledWith(
|
|
165
|
+
'/home/user/.tlc',
|
|
166
|
+
{ recursive: true },
|
|
167
|
+
);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('merges with existing config without overwriting other keys', () => {
|
|
171
|
+
const routing = { build: { models: ['codex'], strategy: 'single' } };
|
|
172
|
+
const existingConfig = {
|
|
173
|
+
model_providers: { claude: { type: 'inline' } },
|
|
174
|
+
task_routing: { review: { models: ['gemini'], strategy: 'single' } },
|
|
175
|
+
};
|
|
176
|
+
let writtenData;
|
|
177
|
+
const fs = {
|
|
178
|
+
existsSync: vi.fn(() => true),
|
|
179
|
+
readFileSync: vi.fn(() => JSON.stringify(existingConfig)),
|
|
180
|
+
mkdirSync: vi.fn(),
|
|
181
|
+
writeFileSync: vi.fn((_, data) => { writtenData = data; }),
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
savePersonalRouting({ routing, homeDir: '/home/user', fs });
|
|
185
|
+
|
|
186
|
+
const parsed = JSON.parse(writtenData);
|
|
187
|
+
expect(parsed.model_providers).toEqual({ claude: { type: 'inline' } });
|
|
188
|
+
expect(parsed.task_routing).toEqual(routing);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('creates new file if none exists', () => {
|
|
192
|
+
const routing = { plan: { models: ['claude'], strategy: 'single' } };
|
|
193
|
+
let writtenData;
|
|
194
|
+
const fs = {
|
|
195
|
+
existsSync: vi.fn(() => false),
|
|
196
|
+
readFileSync: vi.fn(() => { throw new Error('ENOENT'); }),
|
|
197
|
+
mkdirSync: vi.fn(),
|
|
198
|
+
writeFileSync: vi.fn((_, data) => { writtenData = data; }),
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
const result = savePersonalRouting({ routing, homeDir: '/home/user', fs });
|
|
202
|
+
|
|
203
|
+
expect(result.saved).toBe(true);
|
|
204
|
+
const parsed = JSON.parse(writtenData);
|
|
205
|
+
expect(parsed.task_routing).toEqual(routing);
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
// ── formatRoutingTable ────────────────────────────────────────────
|
|
210
|
+
describe('formatRoutingTable', () => {
|
|
211
|
+
it('formats entries as aligned text table', () => {
|
|
212
|
+
const table = [
|
|
213
|
+
{ command: 'build', models: ['claude'], strategy: 'single', source: 'shipped-defaults' },
|
|
214
|
+
{ command: 'review', models: ['claude', 'codex'], strategy: 'parallel', source: 'personal-config' },
|
|
215
|
+
];
|
|
216
|
+
|
|
217
|
+
const output = formatRoutingTable(table);
|
|
218
|
+
|
|
219
|
+
expect(output).toContain('build');
|
|
220
|
+
expect(output).toContain('review');
|
|
221
|
+
expect(output).toContain('single');
|
|
222
|
+
expect(output).toContain('parallel');
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it('shows command, models joined with +, strategy, source', () => {
|
|
226
|
+
const table = [
|
|
227
|
+
{ command: 'review', models: ['claude', 'codex'], strategy: 'parallel', source: 'personal-config' },
|
|
228
|
+
];
|
|
229
|
+
|
|
230
|
+
const output = formatRoutingTable(table);
|
|
231
|
+
|
|
232
|
+
expect(output).toContain('claude + codex');
|
|
233
|
+
expect(output).toContain('parallel');
|
|
234
|
+
expect(output).toContain('personal-config');
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it('handles empty table', () => {
|
|
238
|
+
const output = formatRoutingTable([]);
|
|
239
|
+
|
|
240
|
+
expect(output).toContain('No routing');
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
// ── formatProviderList ────────────────────────────────────────────
|
|
245
|
+
describe('formatProviderList', () => {
|
|
246
|
+
it('shows checkmark for found providers', () => {
|
|
247
|
+
const providers = [
|
|
248
|
+
{ name: 'claude', found: true, path: '/usr/bin/claude', version: '1.0.0' },
|
|
249
|
+
];
|
|
250
|
+
|
|
251
|
+
const output = formatProviderList(providers);
|
|
252
|
+
|
|
253
|
+
// Use a checkmark symbol
|
|
254
|
+
expect(output).toMatch(/[✓✔]/);
|
|
255
|
+
expect(output).toContain('claude');
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it('shows X for missing providers', () => {
|
|
259
|
+
const providers = [
|
|
260
|
+
{ name: 'codex', found: false, path: null, version: null },
|
|
261
|
+
];
|
|
262
|
+
|
|
263
|
+
const output = formatProviderList(providers);
|
|
264
|
+
|
|
265
|
+
// Use an X symbol
|
|
266
|
+
expect(output).toMatch(/[✗✘×]/);
|
|
267
|
+
expect(output).toContain('codex');
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it('includes version when available', () => {
|
|
271
|
+
const providers = [
|
|
272
|
+
{ name: 'claude', found: true, path: '/usr/bin/claude', version: '2.1.0' },
|
|
273
|
+
];
|
|
274
|
+
|
|
275
|
+
const output = formatProviderList(providers);
|
|
276
|
+
|
|
277
|
+
expect(output).toContain('2.1.0');
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it('shows path', () => {
|
|
281
|
+
const providers = [
|
|
282
|
+
{ name: 'claude', found: true, path: '/usr/local/bin/claude', version: '1.0.0' },
|
|
283
|
+
];
|
|
284
|
+
|
|
285
|
+
const output = formatProviderList(providers);
|
|
286
|
+
|
|
287
|
+
expect(output).toContain('/usr/local/bin/claude');
|
|
288
|
+
});
|
|
289
|
+
});
|
|
290
|
+
});
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scope Checker
|
|
3
|
+
* Compare diff files against plan task files to detect scope drift
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Extract planned file paths from PLAN.md content.
|
|
8
|
+
* Parses all `**Files:**` sections and collects listed file paths.
|
|
9
|
+
* @param {string} planContent - Raw content of a PLAN.md file
|
|
10
|
+
* @returns {string[]} Deduplicated array of planned file paths
|
|
11
|
+
*/
|
|
12
|
+
function extractPlannedFiles(planContent) {
|
|
13
|
+
if (!planContent) return [];
|
|
14
|
+
|
|
15
|
+
const files = [];
|
|
16
|
+
const seen = new Set();
|
|
17
|
+
const lines = planContent.split('\n');
|
|
18
|
+
let inFilesSection = false;
|
|
19
|
+
|
|
20
|
+
for (const line of lines) {
|
|
21
|
+
if (line.trim().startsWith('**Files:**')) {
|
|
22
|
+
inFilesSection = true;
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (inFilesSection) {
|
|
27
|
+
const match = line.match(/^- (.+)/);
|
|
28
|
+
if (match) {
|
|
29
|
+
// Strip backticks, em-dashes, and parenthetical annotations like (new), (modify)
|
|
30
|
+
const filePath = match[1]
|
|
31
|
+
.replace(/`/g, '')
|
|
32
|
+
.split(' — ')[0]
|
|
33
|
+
.split(' -- ')[0]
|
|
34
|
+
.replace(/\s*\((?:new|modify|delete|update|create)\)\s*$/i, '')
|
|
35
|
+
.trim();
|
|
36
|
+
if (!seen.has(filePath)) {
|
|
37
|
+
seen.add(filePath);
|
|
38
|
+
files.push(filePath);
|
|
39
|
+
}
|
|
40
|
+
} else {
|
|
41
|
+
// Non-list line ends the Files section
|
|
42
|
+
inFilesSection = false;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return files;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Check if a file path is a test file.
|
|
52
|
+
* Matches `.test.`, `_test.`, and `test_` patterns.
|
|
53
|
+
* @param {string} filePath - File path to check
|
|
54
|
+
* @returns {boolean} True if the file is a test file
|
|
55
|
+
*/
|
|
56
|
+
function isTestFile(filePath) {
|
|
57
|
+
const name = filePath.split('/').pop() || filePath;
|
|
58
|
+
// Check filename patterns
|
|
59
|
+
const nameMatch = (
|
|
60
|
+
name.includes('.test.') ||
|
|
61
|
+
name.includes('.spec.') ||
|
|
62
|
+
name.includes('_test.') ||
|
|
63
|
+
name.startsWith('test_')
|
|
64
|
+
);
|
|
65
|
+
if (nameMatch) return true;
|
|
66
|
+
|
|
67
|
+
// Check directory-based test paths (tests/, __tests__/, test/, spec/)
|
|
68
|
+
const dirMatch = /(?:^|\/)(?:tests|__tests__|test|spec)\//.test(filePath);
|
|
69
|
+
return dirMatch;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Detect scope drift between diff files and planned files.
|
|
74
|
+
* Returns files present in diff but not in plan (drift),
|
|
75
|
+
* and files in plan but not in diff (missing).
|
|
76
|
+
* Test files are excluded from drift detection.
|
|
77
|
+
* @param {string[]} diffFiles - Files changed in the diff
|
|
78
|
+
* @param {string[]} plannedFiles - Files listed in the plan
|
|
79
|
+
* @returns {{ drift: string[], missing: string[] }} Drift report
|
|
80
|
+
*/
|
|
81
|
+
function detectDrift(diffFiles, plannedFiles) {
|
|
82
|
+
if (!diffFiles || !plannedFiles) return { drift: [], missing: [] };
|
|
83
|
+
|
|
84
|
+
const plannedSet = new Set(plannedFiles);
|
|
85
|
+
const diffSet = new Set(diffFiles);
|
|
86
|
+
|
|
87
|
+
const drift = diffFiles.filter(
|
|
88
|
+
(f) => !plannedSet.has(f) && !isTestFile(f)
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
const missing = plannedFiles.filter((f) => !diffSet.has(f));
|
|
92
|
+
|
|
93
|
+
return { drift, missing };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Format a drift report as markdown.
|
|
98
|
+
* Returns "No drift detected" when both arrays are empty.
|
|
99
|
+
* @param {string[]} drift - Files in diff but not in plan
|
|
100
|
+
* @param {string[]} missing - Files in plan but not in diff
|
|
101
|
+
* @returns {string} Formatted markdown report
|
|
102
|
+
*/
|
|
103
|
+
function formatDriftReport(drift, missing) {
|
|
104
|
+
if ((!drift || drift.length === 0) && (!missing || missing.length === 0)) {
|
|
105
|
+
return 'No drift detected';
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const sections = [];
|
|
109
|
+
|
|
110
|
+
if (drift && drift.length > 0) {
|
|
111
|
+
sections.push(
|
|
112
|
+
'### Scope Drift\n\nFiles changed but not in plan:\n\n' +
|
|
113
|
+
drift.map((f) => `- \`${f}\``).join('\n')
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (missing && missing.length > 0) {
|
|
118
|
+
sections.push(
|
|
119
|
+
'### Missing Work\n\nPlanned files not yet changed:\n\n' +
|
|
120
|
+
missing.map((f) => `- \`${f}\``).join('\n')
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return sections.join('\n\n');
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
module.exports = { extractPlannedFiles, isTestFile, detectDrift, formatDriftReport };
|