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,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Command Router
|
|
3
|
+
*
|
|
4
|
+
* Integration point for TLC command routing. Given a command name,
|
|
5
|
+
* resolves routing config, checks provider availability, packages
|
|
6
|
+
* prompts, and dispatches to the appropriate execution path.
|
|
7
|
+
*
|
|
8
|
+
* @module command-router
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const { buildProviderCommand } = require('./cli-dispatcher.js');
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Check whether a model is available in the router state.
|
|
15
|
+
* @param {string} model - Model name to check
|
|
16
|
+
* @param {Object} routerState - Parsed .tlc/.router-state.json
|
|
17
|
+
* @returns {boolean} True if the model's provider is available
|
|
18
|
+
*/
|
|
19
|
+
function isModelAvailable(model, routerState, providerConfig) {
|
|
20
|
+
if (model === 'claude') return true;
|
|
21
|
+
// Check router state (probed CLIs) first
|
|
22
|
+
const probed = routerState?.providers?.[model];
|
|
23
|
+
if (probed?.available === true) return true;
|
|
24
|
+
// Also accept user-defined provider aliases from model_providers config
|
|
25
|
+
if (providerConfig?.[model]?.type === 'cli') return true;
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Route a single model to its result.
|
|
31
|
+
* @param {string} model - Model name
|
|
32
|
+
* @param {Object} opts - Routing options
|
|
33
|
+
* @param {string} opts.agentPrompt - Command instruction text
|
|
34
|
+
* @param {Object} opts.context - Project context
|
|
35
|
+
* @param {Object|undefined} opts.providerConfig - Provider config for CLI models
|
|
36
|
+
* @param {Object} opts.routerState - Router state from .router-state.json
|
|
37
|
+
* @param {Object} deps - Injected dependencies
|
|
38
|
+
* @returns {Promise<Object>} Result for this model
|
|
39
|
+
*/
|
|
40
|
+
async function routeModel(model, { agentPrompt, context, providerConfig, routerState }, deps) {
|
|
41
|
+
// Claude is always inline
|
|
42
|
+
if (model === 'claude') {
|
|
43
|
+
return { model: 'claude', type: 'inline' };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Check availability — return skipped (not duplicate Claude) so parallel doesn't double-run
|
|
47
|
+
if (!isModelAvailable(model, routerState, providerConfig)) {
|
|
48
|
+
return {
|
|
49
|
+
type: 'skipped',
|
|
50
|
+
model,
|
|
51
|
+
warning: `${model} is unavailable`,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Get provider command config
|
|
56
|
+
const provider = providerConfig?.[model];
|
|
57
|
+
if (!provider) {
|
|
58
|
+
return {
|
|
59
|
+
type: 'skipped',
|
|
60
|
+
model,
|
|
61
|
+
warning: `${model} has no provider config`,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const cmdSpec = buildProviderCommand(provider);
|
|
66
|
+
if (!cmdSpec) {
|
|
67
|
+
return {
|
|
68
|
+
type: 'skipped',
|
|
69
|
+
model,
|
|
70
|
+
warning: `${model} is not a CLI provider`,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Package prompt with full context
|
|
75
|
+
const prompt = deps.packagePrompt({
|
|
76
|
+
agentPrompt,
|
|
77
|
+
projectDoc: context.projectDoc || null,
|
|
78
|
+
planDoc: context.planDoc || null,
|
|
79
|
+
codingStandards: context.codingStandards || null,
|
|
80
|
+
files: context.files || null,
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
// Dispatch to CLI
|
|
84
|
+
const dispatchResult = await deps.dispatch({
|
|
85
|
+
command: cmdSpec.command,
|
|
86
|
+
args: cmdSpec.args,
|
|
87
|
+
prompt,
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
type: 'cli',
|
|
92
|
+
model,
|
|
93
|
+
output: dispatchResult.stdout,
|
|
94
|
+
stderr: dispatchResult.stderr,
|
|
95
|
+
exitCode: dispatchResult.exitCode,
|
|
96
|
+
duration: dispatchResult.duration,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Route a TLC command to the appropriate execution path.
|
|
102
|
+
* @param {Object} options
|
|
103
|
+
* @param {string} options.command - TLC command name (e.g., 'build', 'review')
|
|
104
|
+
* @param {string} options.agentPrompt - The command's instruction text
|
|
105
|
+
* @param {string|null} options.flagModel - --model flag override
|
|
106
|
+
* @param {Object} options.context - Project context for prompt packaging
|
|
107
|
+
* @param {string|null} options.context.projectDoc - PROJECT.md contents
|
|
108
|
+
* @param {string|null} options.context.planDoc - Current phase PLAN.md
|
|
109
|
+
* @param {string|null} options.context.codingStandards - CODING-STANDARDS.md
|
|
110
|
+
* @param {Array|null} options.context.files - [{path, content}]
|
|
111
|
+
* @param {Object} deps - Injected dependencies for testability
|
|
112
|
+
* @param {string} [options.projectDir] - Project directory for config loading
|
|
113
|
+
* @param {string} [options.homeDir] - Home directory for personal config
|
|
114
|
+
* @param {Object} deps - Injected dependencies for testability
|
|
115
|
+
* @param {Function} deps.resolveRouting - from task-router-config
|
|
116
|
+
* @param {Function} deps.packagePrompt - from prompt-packager
|
|
117
|
+
* @param {Function} deps.dispatch - from cli-dispatcher
|
|
118
|
+
* @param {Function} deps.readRouterState - reads .tlc/.router-state.json
|
|
119
|
+
* @returns {Promise<Object>} Result
|
|
120
|
+
*/
|
|
121
|
+
async function routeCommand({ command, agentPrompt, flagModel, context, projectDir, homeDir }, deps) {
|
|
122
|
+
// Resolve routing config — pass full context so personal + project configs are loaded
|
|
123
|
+
const routing = deps.resolveRouting({ command, flagModel, projectDir, homeDir });
|
|
124
|
+
|
|
125
|
+
// Read router state for provider availability
|
|
126
|
+
const routerState = deps.readRouterState();
|
|
127
|
+
|
|
128
|
+
const { models, strategy, providers: providerConfig } = routing;
|
|
129
|
+
|
|
130
|
+
// Single strategy — fall back to Claude if target is unavailable
|
|
131
|
+
if (strategy === 'single') {
|
|
132
|
+
const model = models[0];
|
|
133
|
+
const result = await routeModel(model, {
|
|
134
|
+
agentPrompt,
|
|
135
|
+
context,
|
|
136
|
+
providerConfig,
|
|
137
|
+
routerState,
|
|
138
|
+
}, deps);
|
|
139
|
+
|
|
140
|
+
// For single strategy, skipped means fall back to inline
|
|
141
|
+
if (result.type === 'skipped') {
|
|
142
|
+
return { type: 'inline', model: 'claude', warning: result.warning + ', falling back to inline (claude)' };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return result;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Parallel strategy
|
|
149
|
+
if (strategy === 'parallel') {
|
|
150
|
+
const promises = models.map((model) =>
|
|
151
|
+
routeModel(model, {
|
|
152
|
+
agentPrompt,
|
|
153
|
+
context,
|
|
154
|
+
providerConfig,
|
|
155
|
+
routerState,
|
|
156
|
+
}, deps)
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
const results = await Promise.all(promises);
|
|
160
|
+
|
|
161
|
+
return {
|
|
162
|
+
type: 'parallel',
|
|
163
|
+
results,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Unknown strategy — fall back to inline
|
|
168
|
+
return { type: 'inline', model: 'claude' };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
module.exports = { routeCommand };
|
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { routeCommand } from './command-router.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Create default mock dependencies for routeCommand.
|
|
6
|
+
* @param {Object} overrides - Override individual deps
|
|
7
|
+
* @returns {Object} Deps object with all mocks
|
|
8
|
+
*/
|
|
9
|
+
function createMockDeps(overrides = {}) {
|
|
10
|
+
return {
|
|
11
|
+
resolveRouting: vi.fn(() => ({
|
|
12
|
+
models: ['claude'],
|
|
13
|
+
strategy: 'single',
|
|
14
|
+
source: 'shipped-defaults',
|
|
15
|
+
})),
|
|
16
|
+
packagePrompt: vi.fn(() => 'packaged-prompt-text'),
|
|
17
|
+
dispatch: vi.fn(async () => ({
|
|
18
|
+
stdout: 'cli output',
|
|
19
|
+
stderr: '',
|
|
20
|
+
exitCode: 0,
|
|
21
|
+
duration: 500,
|
|
22
|
+
})),
|
|
23
|
+
readRouterState: vi.fn(() => ({
|
|
24
|
+
providers: {
|
|
25
|
+
claude: { available: true, path: '/opt/homebrew/bin/claude' },
|
|
26
|
+
codex: { available: true, path: '/opt/homebrew/bin/codex' },
|
|
27
|
+
gemini: { available: false, path: '' },
|
|
28
|
+
},
|
|
29
|
+
})),
|
|
30
|
+
...overrides,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe('command-router', () => {
|
|
35
|
+
describe('routeCommand', () => {
|
|
36
|
+
it('routes to inline when model is claude', async () => {
|
|
37
|
+
const deps = createMockDeps();
|
|
38
|
+
|
|
39
|
+
const result = await routeCommand({
|
|
40
|
+
command: 'build',
|
|
41
|
+
agentPrompt: 'Build the feature',
|
|
42
|
+
flagModel: null,
|
|
43
|
+
context: { projectDoc: null, planDoc: null, codingStandards: null, files: null },
|
|
44
|
+
}, deps);
|
|
45
|
+
|
|
46
|
+
expect(result).toEqual({ type: 'inline', model: 'claude' });
|
|
47
|
+
expect(deps.resolveRouting).toHaveBeenCalledWith(expect.objectContaining({ command: 'build' }));
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('routes to CLI dispatch when model is codex', async () => {
|
|
51
|
+
const deps = createMockDeps({
|
|
52
|
+
resolveRouting: vi.fn(() => ({
|
|
53
|
+
models: ['codex'],
|
|
54
|
+
strategy: 'single',
|
|
55
|
+
source: 'personal-config',
|
|
56
|
+
providers: {
|
|
57
|
+
codex: { type: 'cli', command: 'codex', flags: ['--quiet'] },
|
|
58
|
+
},
|
|
59
|
+
})),
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
const result = await routeCommand({
|
|
63
|
+
command: 'review',
|
|
64
|
+
agentPrompt: 'Review the code',
|
|
65
|
+
flagModel: null,
|
|
66
|
+
context: { projectDoc: 'My Project', planDoc: null, codingStandards: null, files: null },
|
|
67
|
+
}, deps);
|
|
68
|
+
|
|
69
|
+
expect(result.type).toBe('cli');
|
|
70
|
+
expect(result.model).toBe('codex');
|
|
71
|
+
expect(result.output).toBe('cli output');
|
|
72
|
+
expect(result.exitCode).toBe(0);
|
|
73
|
+
expect(result.duration).toBe(500);
|
|
74
|
+
expect(deps.packagePrompt).toHaveBeenCalled();
|
|
75
|
+
expect(deps.dispatch).toHaveBeenCalled();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('parallel strategy returns both inline and CLI results', async () => {
|
|
79
|
+
const deps = createMockDeps({
|
|
80
|
+
resolveRouting: vi.fn(() => ({
|
|
81
|
+
models: ['claude', 'codex'],
|
|
82
|
+
strategy: 'parallel',
|
|
83
|
+
source: 'project-override',
|
|
84
|
+
providers: {
|
|
85
|
+
codex: { type: 'cli', command: 'codex', flags: [] },
|
|
86
|
+
},
|
|
87
|
+
})),
|
|
88
|
+
dispatch: vi.fn(async () => ({
|
|
89
|
+
stdout: 'codex says hello',
|
|
90
|
+
stderr: '',
|
|
91
|
+
exitCode: 0,
|
|
92
|
+
duration: 800,
|
|
93
|
+
})),
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
const result = await routeCommand({
|
|
97
|
+
command: 'build',
|
|
98
|
+
agentPrompt: 'Build it',
|
|
99
|
+
flagModel: null,
|
|
100
|
+
context: { projectDoc: null, planDoc: null, codingStandards: null, files: null },
|
|
101
|
+
}, deps);
|
|
102
|
+
|
|
103
|
+
expect(result.type).toBe('parallel');
|
|
104
|
+
expect(result.results).toHaveLength(2);
|
|
105
|
+
|
|
106
|
+
const claudeResult = result.results.find(r => r.model === 'claude');
|
|
107
|
+
const codexResult = result.results.find(r => r.model === 'codex');
|
|
108
|
+
expect(claudeResult).toEqual({ model: 'claude', type: 'inline' });
|
|
109
|
+
expect(codexResult.type).toBe('cli');
|
|
110
|
+
expect(codexResult.model).toBe('codex');
|
|
111
|
+
expect(codexResult.output).toBe('codex says hello');
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('unavailable provider falls back to inline with warning', async () => {
|
|
115
|
+
const deps = createMockDeps({
|
|
116
|
+
resolveRouting: vi.fn(() => ({
|
|
117
|
+
models: ['gemini'],
|
|
118
|
+
strategy: 'single',
|
|
119
|
+
source: 'personal-config',
|
|
120
|
+
// No provider config — truly unknown model
|
|
121
|
+
})),
|
|
122
|
+
readRouterState: vi.fn(() => ({
|
|
123
|
+
providers: {
|
|
124
|
+
claude: { available: true },
|
|
125
|
+
},
|
|
126
|
+
})),
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
const result = await routeCommand({
|
|
130
|
+
command: 'review',
|
|
131
|
+
agentPrompt: 'Review this',
|
|
132
|
+
flagModel: null,
|
|
133
|
+
context: { projectDoc: null, planDoc: null, codingStandards: null, files: null },
|
|
134
|
+
}, deps);
|
|
135
|
+
|
|
136
|
+
expect(result.type).toBe('inline');
|
|
137
|
+
expect(result.model).toBe('claude');
|
|
138
|
+
expect(result.warning).toMatch(/gemini/i);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('flag override routes to specified model', async () => {
|
|
142
|
+
const deps = createMockDeps({
|
|
143
|
+
resolveRouting: vi.fn(() => ({
|
|
144
|
+
models: ['codex'],
|
|
145
|
+
strategy: 'single',
|
|
146
|
+
source: 'flag-override',
|
|
147
|
+
providers: {
|
|
148
|
+
codex: { type: 'cli', command: 'codex', flags: ['--quiet'] },
|
|
149
|
+
},
|
|
150
|
+
})),
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
const result = await routeCommand({
|
|
154
|
+
command: 'build',
|
|
155
|
+
agentPrompt: 'Build it',
|
|
156
|
+
flagModel: 'codex',
|
|
157
|
+
context: { projectDoc: null, planDoc: null, codingStandards: null, files: null },
|
|
158
|
+
}, deps);
|
|
159
|
+
|
|
160
|
+
expect(result.type).toBe('cli');
|
|
161
|
+
expect(result.model).toBe('codex');
|
|
162
|
+
expect(deps.resolveRouting).toHaveBeenCalledWith(expect.objectContaining({
|
|
163
|
+
flagModel: 'codex',
|
|
164
|
+
}));
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('packages prompt with all context fields', async () => {
|
|
168
|
+
const context = {
|
|
169
|
+
projectDoc: 'Project doc content',
|
|
170
|
+
planDoc: 'Plan doc content',
|
|
171
|
+
codingStandards: 'Standards content',
|
|
172
|
+
files: [{ path: 'src/app.js', content: 'const x = 1;' }],
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
const deps = createMockDeps({
|
|
176
|
+
resolveRouting: vi.fn(() => ({
|
|
177
|
+
models: ['codex'],
|
|
178
|
+
strategy: 'single',
|
|
179
|
+
source: 'personal-config',
|
|
180
|
+
providers: {
|
|
181
|
+
codex: { type: 'cli', command: 'codex', flags: [] },
|
|
182
|
+
},
|
|
183
|
+
})),
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
await routeCommand({
|
|
187
|
+
command: 'build',
|
|
188
|
+
agentPrompt: 'Build the feature',
|
|
189
|
+
flagModel: null,
|
|
190
|
+
context,
|
|
191
|
+
}, deps);
|
|
192
|
+
|
|
193
|
+
expect(deps.packagePrompt).toHaveBeenCalledWith(expect.objectContaining({
|
|
194
|
+
agentPrompt: 'Build the feature',
|
|
195
|
+
projectDoc: 'Project doc content',
|
|
196
|
+
planDoc: 'Plan doc content',
|
|
197
|
+
codingStandards: 'Standards content',
|
|
198
|
+
files: [{ path: 'src/app.js', content: 'const x = 1;' }],
|
|
199
|
+
}));
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it('CLI dispatch timeout handled gracefully', async () => {
|
|
203
|
+
const deps = createMockDeps({
|
|
204
|
+
resolveRouting: vi.fn(() => ({
|
|
205
|
+
models: ['codex'],
|
|
206
|
+
strategy: 'single',
|
|
207
|
+
source: 'personal-config',
|
|
208
|
+
providers: {
|
|
209
|
+
codex: { type: 'cli', command: 'codex', flags: [] },
|
|
210
|
+
},
|
|
211
|
+
})),
|
|
212
|
+
dispatch: vi.fn(async () => ({
|
|
213
|
+
stdout: '',
|
|
214
|
+
stderr: 'Process timed out',
|
|
215
|
+
exitCode: -1,
|
|
216
|
+
duration: 120000,
|
|
217
|
+
})),
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
const result = await routeCommand({
|
|
221
|
+
command: 'build',
|
|
222
|
+
agentPrompt: 'Build it',
|
|
223
|
+
flagModel: null,
|
|
224
|
+
context: { projectDoc: null, planDoc: null, codingStandards: null, files: null },
|
|
225
|
+
}, deps);
|
|
226
|
+
|
|
227
|
+
expect(result.type).toBe('cli');
|
|
228
|
+
expect(result.model).toBe('codex');
|
|
229
|
+
expect(result.exitCode).toBe(-1);
|
|
230
|
+
expect(result.output).toBe('');
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it('non-zero exit code from CLI captured in result', async () => {
|
|
234
|
+
const deps = createMockDeps({
|
|
235
|
+
resolveRouting: vi.fn(() => ({
|
|
236
|
+
models: ['codex'],
|
|
237
|
+
strategy: 'single',
|
|
238
|
+
source: 'personal-config',
|
|
239
|
+
providers: {
|
|
240
|
+
codex: { type: 'cli', command: 'codex', flags: [] },
|
|
241
|
+
},
|
|
242
|
+
})),
|
|
243
|
+
dispatch: vi.fn(async () => ({
|
|
244
|
+
stdout: 'partial output',
|
|
245
|
+
stderr: 'error occurred',
|
|
246
|
+
exitCode: 1,
|
|
247
|
+
duration: 300,
|
|
248
|
+
})),
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
const result = await routeCommand({
|
|
252
|
+
command: 'build',
|
|
253
|
+
agentPrompt: 'Build it',
|
|
254
|
+
flagModel: null,
|
|
255
|
+
context: { projectDoc: null, planDoc: null, codingStandards: null, files: null },
|
|
256
|
+
}, deps);
|
|
257
|
+
|
|
258
|
+
expect(result.type).toBe('cli');
|
|
259
|
+
expect(result.exitCode).toBe(1);
|
|
260
|
+
expect(result.output).toBe('partial output');
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it('unknown model falls back to inline', async () => {
|
|
264
|
+
const deps = createMockDeps({
|
|
265
|
+
resolveRouting: vi.fn(() => ({
|
|
266
|
+
models: ['unknown-model'],
|
|
267
|
+
strategy: 'single',
|
|
268
|
+
source: 'flag-override',
|
|
269
|
+
})),
|
|
270
|
+
readRouterState: vi.fn(() => ({
|
|
271
|
+
providers: {
|
|
272
|
+
claude: { available: true, path: '/opt/homebrew/bin/claude' },
|
|
273
|
+
},
|
|
274
|
+
})),
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
const result = await routeCommand({
|
|
278
|
+
command: 'build',
|
|
279
|
+
agentPrompt: 'Build it',
|
|
280
|
+
flagModel: 'unknown-model',
|
|
281
|
+
context: { projectDoc: null, planDoc: null, codingStandards: null, files: null },
|
|
282
|
+
}, deps);
|
|
283
|
+
|
|
284
|
+
expect(result.type).toBe('inline');
|
|
285
|
+
expect(result.model).toBe('claude');
|
|
286
|
+
expect(result.warning).toMatch(/unknown-model/i);
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
it('parallel with one unavailable provider still runs available ones', async () => {
|
|
290
|
+
const deps = createMockDeps({
|
|
291
|
+
resolveRouting: vi.fn(() => ({
|
|
292
|
+
models: ['claude', 'codex', 'gemini'],
|
|
293
|
+
strategy: 'parallel',
|
|
294
|
+
source: 'project-override',
|
|
295
|
+
providers: {
|
|
296
|
+
codex: { type: 'cli', command: 'codex', flags: [] },
|
|
297
|
+
// gemini has no provider config — truly unavailable
|
|
298
|
+
},
|
|
299
|
+
})),
|
|
300
|
+
readRouterState: vi.fn(() => ({
|
|
301
|
+
providers: {
|
|
302
|
+
claude: { available: true },
|
|
303
|
+
codex: { available: true },
|
|
304
|
+
// gemini not in router state either
|
|
305
|
+
},
|
|
306
|
+
})),
|
|
307
|
+
dispatch: vi.fn(async () => ({
|
|
308
|
+
stdout: 'codex output',
|
|
309
|
+
stderr: '',
|
|
310
|
+
exitCode: 0,
|
|
311
|
+
duration: 400,
|
|
312
|
+
})),
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
const result = await routeCommand({
|
|
316
|
+
command: 'build',
|
|
317
|
+
agentPrompt: 'Build it',
|
|
318
|
+
flagModel: null,
|
|
319
|
+
context: { projectDoc: null, planDoc: null, codingStandards: null, files: null },
|
|
320
|
+
}, deps);
|
|
321
|
+
|
|
322
|
+
expect(result.type).toBe('parallel');
|
|
323
|
+
expect(result.results).toHaveLength(3);
|
|
324
|
+
|
|
325
|
+
const [claudeResult, codexResult, geminiResult] = result.results;
|
|
326
|
+
|
|
327
|
+
expect(claudeResult).toEqual({ model: 'claude', type: 'inline' });
|
|
328
|
+
expect(codexResult.type).toBe('cli');
|
|
329
|
+
expect(codexResult.model).toBe('codex');
|
|
330
|
+
// Gemini is unavailable — skipped (not duplicate Claude)
|
|
331
|
+
expect(geminiResult.type).toBe('skipped');
|
|
332
|
+
expect(geminiResult.model).toBe('gemini');
|
|
333
|
+
expect(geminiResult.warning).toMatch(/gemini/i);
|
|
334
|
+
});
|
|
335
|
+
});
|
|
336
|
+
});
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Field Report Module — Agent Self-Rating
|
|
3
|
+
*
|
|
4
|
+
* After build/review, the agent rates its experience and files structured
|
|
5
|
+
* reports when quality falls below threshold (rating < 8).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** Rating threshold — reports are filed when rating is below this value */
|
|
9
|
+
const REPORT_THRESHOLD = 8;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Determine whether a field report should be filed based on the rating.
|
|
13
|
+
* @param {number} rating - Self-assessed quality rating (0–10)
|
|
14
|
+
* @returns {boolean} true if rating < 8, false otherwise
|
|
15
|
+
*/
|
|
16
|
+
function shouldFileReport(rating) {
|
|
17
|
+
if (typeof rating !== 'number') {
|
|
18
|
+
throw new TypeError('rating must be a number');
|
|
19
|
+
}
|
|
20
|
+
return rating < REPORT_THRESHOLD;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Create a formatted field report from the given parameters.
|
|
25
|
+
* @param {object} params
|
|
26
|
+
* @param {string} params.skill - The TLC skill that was executed (e.g. 'tlc:build')
|
|
27
|
+
* @param {number} params.rating - Self-assessed quality rating (0–10)
|
|
28
|
+
* @param {string} params.issue - Description of the issue encountered
|
|
29
|
+
* @param {string} params.suggestion - Suggested improvement
|
|
30
|
+
* @param {() => Date} [params.now] - Optional clock function for deterministic dates
|
|
31
|
+
* @returns {string} Formatted markdown report
|
|
32
|
+
*/
|
|
33
|
+
function createFieldReport({ skill, rating, issue, suggestion, now }) {
|
|
34
|
+
if (!skill) {
|
|
35
|
+
throw new Error('skill is required');
|
|
36
|
+
}
|
|
37
|
+
if (typeof rating !== 'number') {
|
|
38
|
+
throw new TypeError('rating must be a number');
|
|
39
|
+
}
|
|
40
|
+
if (!issue) {
|
|
41
|
+
throw new Error('issue is required');
|
|
42
|
+
}
|
|
43
|
+
if (!suggestion) {
|
|
44
|
+
throw new Error('suggestion is required');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const date = (now ? now() : new Date()).toISOString();
|
|
48
|
+
const critical = rating === 0 ? ' CRITICAL' : '';
|
|
49
|
+
const heading = `## ${date} ${skill} ${rating}/10${critical} — ${issue}`;
|
|
50
|
+
const body = `**Suggestion:** ${suggestion}`;
|
|
51
|
+
|
|
52
|
+
return `${heading}\n\n${body}\n`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Format a report string as a single markdown section.
|
|
57
|
+
* Ensures the entry ends with exactly one trailing newline.
|
|
58
|
+
* @param {string} report - Raw report content from createFieldReport
|
|
59
|
+
* @returns {string} Formatted markdown section with trailing newline
|
|
60
|
+
*/
|
|
61
|
+
function formatReportEntry(report) {
|
|
62
|
+
if (typeof report !== 'string') {
|
|
63
|
+
throw new TypeError('report must be a string');
|
|
64
|
+
}
|
|
65
|
+
return report.trimEnd() + '\n';
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Append a new report to existing file content.
|
|
70
|
+
* If existing content is empty, creates a new document with a header.
|
|
71
|
+
* @param {string} existingContent - Current file content (may be empty)
|
|
72
|
+
* @param {string} newReport - New report to append
|
|
73
|
+
* @returns {string} Combined content
|
|
74
|
+
*/
|
|
75
|
+
function appendReport(existingContent, newReport) {
|
|
76
|
+
if (typeof existingContent !== 'string') {
|
|
77
|
+
throw new TypeError('existingContent must be a string');
|
|
78
|
+
}
|
|
79
|
+
if (typeof newReport !== 'string') {
|
|
80
|
+
throw new TypeError('newReport must be a string');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const entry = formatReportEntry(newReport);
|
|
84
|
+
|
|
85
|
+
if (!existingContent) {
|
|
86
|
+
return `# Field Reports\n\n${entry}`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return `${existingContent}\n\n---\n\n${entry}`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
module.exports = { shouldFileReport, createFieldReport, formatReportEntry, appendReport };
|