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.
Files changed (49) hide show
  1. package/.claude/agents/builder.md +17 -0
  2. package/.claude/commands/tlc/audit.md +12 -0
  3. package/.claude/commands/tlc/autofix.md +31 -0
  4. package/.claude/commands/tlc/build.md +98 -24
  5. package/.claude/commands/tlc/coverage.md +31 -0
  6. package/.claude/commands/tlc/discuss.md +31 -0
  7. package/.claude/commands/tlc/docs.md +31 -0
  8. package/.claude/commands/tlc/edge-cases.md +31 -0
  9. package/.claude/commands/tlc/guard.md +9 -0
  10. package/.claude/commands/tlc/init.md +12 -1
  11. package/.claude/commands/tlc/plan.md +31 -0
  12. package/.claude/commands/tlc/quick.md +31 -0
  13. package/.claude/commands/tlc/review.md +50 -0
  14. package/.claude/hooks/tlc-session-init.sh +14 -3
  15. package/CODING-STANDARDS.md +217 -10
  16. package/bin/setup-autoupdate.js +316 -87
  17. package/bin/setup-autoupdate.test.js +454 -34
  18. package/package.json +1 -1
  19. package/scripts/project-docs.js +1 -1
  20. package/server/lib/careful-patterns.js +142 -0
  21. package/server/lib/careful-patterns.test.js +164 -0
  22. package/server/lib/cli-dispatcher.js +98 -0
  23. package/server/lib/cli-dispatcher.test.js +249 -0
  24. package/server/lib/command-router.js +171 -0
  25. package/server/lib/command-router.test.js +336 -0
  26. package/server/lib/field-report.js +92 -0
  27. package/server/lib/field-report.test.js +195 -0
  28. package/server/lib/orchestration/worktree-manager.js +133 -0
  29. package/server/lib/orchestration/worktree-manager.test.js +198 -0
  30. package/server/lib/overdrive-command.js +31 -9
  31. package/server/lib/overdrive-command.test.js +25 -26
  32. package/server/lib/prompt-packager.js +98 -0
  33. package/server/lib/prompt-packager.test.js +185 -0
  34. package/server/lib/review-fixer.js +107 -0
  35. package/server/lib/review-fixer.test.js +152 -0
  36. package/server/lib/routing-command.js +159 -0
  37. package/server/lib/routing-command.test.js +290 -0
  38. package/server/lib/scope-checker.js +127 -0
  39. package/server/lib/scope-checker.test.js +175 -0
  40. package/server/lib/skill-validator.js +165 -0
  41. package/server/lib/skill-validator.test.js +289 -0
  42. package/server/lib/standards/standards-injector.js +6 -0
  43. package/server/lib/task-router-config.js +142 -0
  44. package/server/lib/task-router-config.test.js +428 -0
  45. package/server/lib/test-selector.js +127 -0
  46. package/server/lib/test-selector.test.js +172 -0
  47. package/server/setup.sh +271 -271
  48. package/server/templates/CLAUDE.md +6 -0
  49. 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 };