tlc-claude-code 2.5.0 → 2.6.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 (76) hide show
  1. package/.claude/commands/tlc/autofix.md +34 -1
  2. package/.claude/commands/tlc/build.md +89 -6
  3. package/.claude/commands/tlc/ci.md +178 -414
  4. package/.claude/commands/tlc/coverage.md +34 -0
  5. package/.claude/commands/tlc/deploy.md +19 -6
  6. package/.claude/commands/tlc/discuss.md +34 -0
  7. package/.claude/commands/tlc/docs.md +35 -1
  8. package/.claude/commands/tlc/e2e.md +300 -0
  9. package/.claude/commands/tlc/edge-cases.md +35 -1
  10. package/.claude/commands/tlc/init.md +38 -8
  11. package/.claude/commands/tlc/new-project.md +46 -4
  12. package/.claude/commands/tlc/plan.md +33 -0
  13. package/.claude/commands/tlc/quick.md +33 -0
  14. package/.claude/commands/tlc/release.md +85 -135
  15. package/.claude/commands/tlc/restore.md +14 -0
  16. package/.claude/commands/tlc/review.md +76 -1
  17. package/.claude/commands/tlc/tlc.md +134 -0
  18. package/.claude/commands/tlc/verify.md +64 -65
  19. package/.claude/commands/tlc/watchci.md +10 -0
  20. package/.claude/hooks/tlc-block-tools.sh +13 -0
  21. package/.claude/hooks/tlc-session-init.sh +9 -0
  22. package/CODING-STANDARDS.md +35 -10
  23. package/package.json +1 -1
  24. package/server/lib/block-tools-hook.js +23 -0
  25. package/server/lib/e2e/acceptance-parser.js +132 -0
  26. package/server/lib/e2e/acceptance-parser.test.js +110 -0
  27. package/server/lib/e2e/framework-detector.js +47 -0
  28. package/server/lib/e2e/framework-detector.test.js +94 -0
  29. package/server/lib/e2e/log-assertions.js +107 -0
  30. package/server/lib/e2e/log-assertions.test.js +68 -0
  31. package/server/lib/e2e/test-generator.js +159 -0
  32. package/server/lib/e2e/test-generator.test.js +121 -0
  33. package/server/lib/e2e/verify-runner.js +191 -0
  34. package/server/lib/e2e/verify-runner.test.js +167 -0
  35. package/server/lib/hooks/block-tools-hook.test.js +54 -0
  36. package/server/lib/orchestration/cli-dispatch.js +16 -1
  37. package/server/lib/orchestration/cli-dispatch.test.js +94 -8
  38. package/server/lib/orchestration/completion-checker.js +101 -0
  39. package/server/lib/orchestration/completion-checker.test.js +177 -0
  40. package/server/lib/orchestration/result-verifier.js +143 -0
  41. package/server/lib/orchestration/result-verifier.test.js +291 -0
  42. package/server/lib/orchestration/session-dispatcher.js +99 -0
  43. package/server/lib/orchestration/session-dispatcher.test.js +215 -0
  44. package/server/lib/orchestration/session-status.js +147 -0
  45. package/server/lib/orchestration/session-status.test.js +130 -0
  46. package/server/lib/release/agent-runner-updates.js +24 -0
  47. package/server/lib/release/agent-runner-updates.test.js +22 -0
  48. package/server/lib/release/changelog-generator.js +142 -0
  49. package/server/lib/release/changelog-generator.test.js +113 -0
  50. package/server/lib/release/ci-watcher.js +83 -0
  51. package/server/lib/release/ci-watcher.test.js +81 -0
  52. package/server/lib/release/health-checker.js +111 -0
  53. package/server/lib/release/health-checker.test.js +121 -0
  54. package/server/lib/release/release-pipeline.js +187 -0
  55. package/server/lib/release/release-pipeline.test.js +262 -0
  56. package/server/lib/release/version-bumper.js +183 -0
  57. package/server/lib/release/version-bumper.test.js +142 -0
  58. package/server/lib/routing-preamble.integration.test.js +12 -0
  59. package/server/lib/routing-preamble.js +13 -2
  60. package/server/lib/routing-preamble.test.js +49 -0
  61. package/server/lib/scaffolding/ci-detector.js +139 -0
  62. package/server/lib/scaffolding/ci-detector.test.js +198 -0
  63. package/server/lib/scaffolding/ci-scaffolder.js +347 -0
  64. package/server/lib/scaffolding/ci-scaffolder.test.js +157 -0
  65. package/server/lib/scaffolding/deploy-detector.js +135 -0
  66. package/server/lib/scaffolding/deploy-detector.test.js +106 -0
  67. package/server/lib/scaffolding/health-scaffold.js +374 -0
  68. package/server/lib/scaffolding/health-scaffold.test.js +99 -0
  69. package/server/lib/scaffolding/logger-scaffold.js +196 -0
  70. package/server/lib/scaffolding/logger-scaffold.test.js +146 -0
  71. package/server/lib/scaffolding/migration-detector.js +78 -0
  72. package/server/lib/scaffolding/migration-detector.test.js +127 -0
  73. package/server/lib/scaffolding/snapshot-manager.js +142 -0
  74. package/server/lib/scaffolding/snapshot-manager.test.js +225 -0
  75. package/server/lib/task-router-config.js +50 -20
  76. package/server/lib/task-router-config.test.js +29 -15
@@ -0,0 +1,262 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import { createRequire } from 'node:module';
3
+ import fsp from 'node:fs/promises';
4
+
5
+ const require = createRequire(import.meta.url);
6
+
7
+ let runRelease;
8
+ let ciWatcher;
9
+ let versionBumper;
10
+ let changelogGenerator;
11
+ let healthChecker;
12
+
13
+ describe('release/release-pipeline', () => {
14
+ beforeEach(() => {
15
+ vi.resetModules();
16
+ vi.clearAllMocks();
17
+
18
+ ciWatcher = require('./ci-watcher.js');
19
+ versionBumper = require('./version-bumper.js');
20
+ changelogGenerator = require('./changelog-generator.js');
21
+ healthChecker = require('./health-checker.js');
22
+
23
+ vi.spyOn(fsp, 'readFile').mockImplementation(async (filePath) => {
24
+ if (String(filePath).endsWith('package.json')) {
25
+ return JSON.stringify({ name: 'tlc-server', version: '0.1.2' });
26
+ }
27
+
28
+ const error = new Error('missing');
29
+ error.code = 'ENOENT';
30
+ throw error;
31
+ });
32
+ vi.spyOn(fsp, 'mkdir').mockResolvedValue(undefined);
33
+ vi.spyOn(fsp, 'writeFile').mockResolvedValue(undefined);
34
+
35
+ vi.spyOn(ciWatcher, 'watchCi').mockResolvedValue('green');
36
+ vi.spyOn(versionBumper, 'determineNextVersion').mockReturnValue('0.2.0');
37
+ vi.spyOn(versionBumper, 'applyVersionBump').mockResolvedValue({ version: '0.2.0' });
38
+ vi.spyOn(changelogGenerator, 'generateChangelog')
39
+ .mockReturnValue('## v0.2.0 - 2026-03-31\n\n- Release notes');
40
+ vi.spyOn(changelogGenerator, 'prependToChangelog')
41
+ .mockReturnValue('# Changelog\n\n## v0.2.0 - 2026-03-31\n\n- Release notes\n');
42
+ vi.spyOn(healthChecker, 'checkHealth').mockResolvedValue({
43
+ ok: true,
44
+ attempts: 1,
45
+ status: 200,
46
+ error: null,
47
+ });
48
+ vi.spyOn(healthChecker, 'rollback').mockResolvedValue({
49
+ ok: true,
50
+ branch: 'main',
51
+ previousSha: 'abc123',
52
+ });
53
+
54
+ ({ runRelease } = require('./release-pipeline.js'));
55
+ });
56
+
57
+ it('runs the full release pipeline and records a successful completion', async () => {
58
+ const exec = vi.fn(async (command) => {
59
+ if (command === 'git rev-parse HEAD') {
60
+ return { stdout: 'abc123\n' };
61
+ }
62
+
63
+ if (command === 'git log -20 --pretty=format:%H%x09%s') {
64
+ return {
65
+ stdout: [
66
+ '1111111\tfeat: add automated release pipeline',
67
+ '2222222\tfix: handle flaky health polling',
68
+ ].join('\n'),
69
+ };
70
+ }
71
+
72
+ return { stdout: '' };
73
+ });
74
+ const fetch = vi.fn();
75
+
76
+ const result = await runRelease({
77
+ branch: 'main',
78
+ config: {
79
+ deploy: { healthUrl: 'https://example.com/health' },
80
+ publish: { npm: true },
81
+ },
82
+ exec,
83
+ fetch,
84
+ });
85
+
86
+ expect(ciWatcher.watchCi).toHaveBeenCalledWith({ branch: 'main', exec });
87
+ expect(versionBumper.determineNextVersion).toHaveBeenCalledWith({
88
+ currentVersion: '0.1.2',
89
+ commits: [
90
+ { sha: '1111111', message: 'feat: add automated release pipeline' },
91
+ { sha: '2222222', message: 'fix: handle flaky health polling' },
92
+ ],
93
+ });
94
+ expect(changelogGenerator.generateChangelog).toHaveBeenCalledWith({
95
+ version: 'v0.2.0',
96
+ date: expect.stringMatching(/^\d{4}-\d{2}-\d{2}$/),
97
+ commits: [
98
+ { sha: '1111111', message: 'feat: add automated release pipeline' },
99
+ { sha: '2222222', message: 'fix: handle flaky health polling' },
100
+ ],
101
+ });
102
+ expect(versionBumper.applyVersionBump).toHaveBeenCalledWith({
103
+ version: '0.2.0',
104
+ packageJsonPath: expect.stringContaining('/server/package.json'),
105
+ fs: expect.objectContaining({
106
+ readFile: expect.any(Function),
107
+ writeFile: expect.any(Function),
108
+ }),
109
+ });
110
+ expect(changelogGenerator.prependToChangelog).toHaveBeenCalledWith({
111
+ changelogPath: expect.stringContaining('/server/CHANGELOG.md'),
112
+ content: '## v0.2.0 - 2026-03-31\n\n- Release notes',
113
+ fs: expect.any(Object),
114
+ });
115
+ expect(exec).toHaveBeenCalledWith(expect.stringContaining("git add"));
116
+ expect(exec).toHaveBeenCalledWith("git commit -m 'chore(release): v0.2.0'");
117
+ expect(exec).toHaveBeenCalledWith('git tag v0.2.0');
118
+ expect(exec).toHaveBeenCalledWith("git push origin 'main'");
119
+ expect(exec).toHaveBeenCalledWith('git push origin v0.2.0');
120
+ expect(exec).toHaveBeenCalledWith('npm publish');
121
+ expect(healthChecker.checkHealth).toHaveBeenCalledWith({
122
+ healthUrl: 'https://example.com/health',
123
+ fetch,
124
+ });
125
+ expect(healthChecker.rollback).not.toHaveBeenCalled();
126
+ expect(fsp.mkdir).toHaveBeenCalledWith(expect.stringContaining('/server/.tlc'), { recursive: true });
127
+ expect(fsp.writeFile).toHaveBeenCalledWith(
128
+ expect.stringContaining('/server/.tlc/.agent-completions.json'),
129
+ expect.stringContaining('"status": "released"'),
130
+ 'utf8'
131
+ );
132
+ expect(result).toEqual({
133
+ ok: true,
134
+ status: 'released',
135
+ branch: 'main',
136
+ version: '0.2.0',
137
+ ciStatus: 'green',
138
+ published: true,
139
+ health: {
140
+ ok: true,
141
+ attempts: 1,
142
+ status: 200,
143
+ error: null,
144
+ },
145
+ summary: 'Release completed for v0.2.0',
146
+ });
147
+ });
148
+
149
+ it('returns a stuck summary when CI does not go green', async () => {
150
+ const exec = vi.fn().mockResolvedValue({ stdout: 'abc123\n' });
151
+ const fetch = vi.fn();
152
+
153
+ ciWatcher.watchCi.mockResolvedValue('stuck');
154
+
155
+ const result = await runRelease({
156
+ branch: 'release/next',
157
+ config: {
158
+ deploy: { healthUrl: 'https://example.com/health' },
159
+ publish: { npm: false },
160
+ },
161
+ exec,
162
+ fetch,
163
+ });
164
+
165
+ expect(versionBumper.determineNextVersion).not.toHaveBeenCalled();
166
+ expect(healthChecker.checkHealth).not.toHaveBeenCalled();
167
+ expect(result).toEqual({
168
+ ok: false,
169
+ status: 'stuck',
170
+ branch: 'release/next',
171
+ ciStatus: 'stuck',
172
+ summary: 'Release halted because CI is stuck',
173
+ });
174
+ expect(fsp.writeFile).toHaveBeenCalledWith(
175
+ expect.stringContaining('/server/.tlc/.agent-completions.json'),
176
+ expect.stringContaining('"status": "stuck"'),
177
+ 'utf8'
178
+ );
179
+ });
180
+
181
+ it('rolls back when the post-release health check fails', async () => {
182
+ const exec = vi.fn(async (command) => {
183
+ if (command === 'git rev-parse HEAD') {
184
+ return { stdout: 'deadbeef\n' };
185
+ }
186
+
187
+ if (command === 'git log -20 --pretty=format:%H%x09%s') {
188
+ return {
189
+ stdout: '3333333\tfix: patch release regression',
190
+ };
191
+ }
192
+
193
+ return { stdout: '' };
194
+ });
195
+ const fetch = vi.fn();
196
+
197
+ healthChecker.checkHealth.mockResolvedValue({
198
+ ok: false,
199
+ attempts: 3,
200
+ status: null,
201
+ error: 'connection refused',
202
+ });
203
+ healthChecker.rollback.mockResolvedValue({
204
+ ok: true,
205
+ branch: 'main',
206
+ previousSha: 'deadbeef',
207
+ });
208
+
209
+ const result = await runRelease({
210
+ branch: 'main',
211
+ config: {
212
+ deploy: { healthUrl: 'https://example.com/health' },
213
+ publish: { npm: false },
214
+ },
215
+ exec,
216
+ fetch,
217
+ });
218
+
219
+ expect(healthChecker.rollback).toHaveBeenCalledWith({
220
+ branch: 'main',
221
+ previousSha: 'deadbeef',
222
+ exec,
223
+ });
224
+ expect(result).toEqual({
225
+ ok: false,
226
+ status: 'rolled_back',
227
+ branch: 'main',
228
+ version: '0.2.0',
229
+ ciStatus: 'green',
230
+ published: false,
231
+ health: {
232
+ ok: false,
233
+ attempts: 3,
234
+ status: null,
235
+ error: 'connection refused',
236
+ },
237
+ rollback: {
238
+ ok: true,
239
+ branch: 'main',
240
+ previousSha: 'deadbeef',
241
+ },
242
+ summary: 'Release rolled back after failing health checks for v0.2.0',
243
+ });
244
+ expect(fsp.writeFile).toHaveBeenCalledWith(
245
+ expect.stringContaining('/server/.tlc/.agent-completions.json'),
246
+ expect.stringContaining('"status": "rolled_back"'),
247
+ 'utf8'
248
+ );
249
+ });
250
+
251
+ it('validates that deploy.healthUrl is configured', async () => {
252
+ await expect(runRelease({
253
+ branch: 'main',
254
+ config: {
255
+ deploy: {},
256
+ publish: { npm: false },
257
+ },
258
+ exec: vi.fn(),
259
+ fetch: vi.fn(),
260
+ })).rejects.toThrow('config.deploy.healthUrl is required');
261
+ });
262
+ });
@@ -0,0 +1,183 @@
1
+ /**
2
+ * Determine the next semantic version from conventional commits and apply it
3
+ * to a package.json file through injected filesystem helpers.
4
+ */
5
+
6
+ const SEMVER_PATTERN = /^v?(\d+)\.(\d+)\.(\d+)$/;
7
+ const BREAKING_CHANGE_PATTERN = /^BREAKING[ -]CHANGE:/m;
8
+ const HEADER_PATTERN = /^([a-z]+)(\([^)]+\))?(!)?:\s+(.+)$/i;
9
+
10
+ /**
11
+ * Parse a simple semver string.
12
+ *
13
+ * @param {string} version
14
+ * @returns {{major: number, minor: number, patch: number}}
15
+ */
16
+ function parseVersion(version) {
17
+ if (typeof version !== 'string') {
18
+ throw new TypeError('currentVersion must be a semver string');
19
+ }
20
+
21
+ const match = version.trim().match(SEMVER_PATTERN);
22
+ if (!match) {
23
+ throw new Error(`Invalid semver version: ${version}`);
24
+ }
25
+
26
+ return {
27
+ major: Number(match[1]),
28
+ minor: Number(match[2]),
29
+ patch: Number(match[3]),
30
+ };
31
+ }
32
+
33
+ /**
34
+ * Normalize a commit item into a message string.
35
+ *
36
+ * @param {string|object} commit
37
+ * @returns {string}
38
+ */
39
+ function getCommitMessage(commit) {
40
+ if (typeof commit === 'string') {
41
+ return commit;
42
+ }
43
+
44
+ if (commit && typeof commit.message === 'string') {
45
+ return commit.message;
46
+ }
47
+
48
+ if (commit && typeof commit.subject === 'string') {
49
+ return commit.subject;
50
+ }
51
+
52
+ return '';
53
+ }
54
+
55
+ /**
56
+ * Determine the bump level represented by a single commit.
57
+ *
58
+ * @param {string} message
59
+ * @returns {'major'|'minor'|'patch'|null}
60
+ */
61
+ function getCommitBump(message) {
62
+ const normalized = message.trim();
63
+ if (!normalized) {
64
+ return null;
65
+ }
66
+
67
+ if (BREAKING_CHANGE_PATTERN.test(normalized)) {
68
+ return 'major';
69
+ }
70
+
71
+ const header = normalized.split('\n', 1)[0];
72
+ const match = header.match(HEADER_PATTERN);
73
+ if (!match) {
74
+ return null;
75
+ }
76
+
77
+ const type = match[1].toLowerCase();
78
+ const breaking = Boolean(match[3]);
79
+
80
+ if (breaking) {
81
+ return 'major';
82
+ }
83
+
84
+ if (type === 'feat') {
85
+ return 'minor';
86
+ }
87
+
88
+ if (type === 'fix' || type === 'perf') {
89
+ return 'patch';
90
+ }
91
+
92
+ return null;
93
+ }
94
+
95
+ /**
96
+ * Bump a parsed version by a bump level.
97
+ *
98
+ * @param {{major: number, minor: number, patch: number}} version
99
+ * @param {'major'|'minor'|'patch'|null} bump
100
+ * @returns {string}
101
+ */
102
+ function bumpVersion(version, bump) {
103
+ if (bump === 'major') {
104
+ return `${version.major + 1}.0.0`;
105
+ }
106
+
107
+ if (bump === 'minor') {
108
+ return `${version.major}.${version.minor + 1}.0`;
109
+ }
110
+
111
+ if (bump === 'patch') {
112
+ return `${version.major}.${version.minor}.${version.patch + 1}`;
113
+ }
114
+
115
+ return `${version.major}.${version.minor}.${version.patch}`;
116
+ }
117
+
118
+ /**
119
+ * Determine the next semantic version from a list of conventional commits.
120
+ *
121
+ * @param {{currentVersion: string, commits: Array<string|{message?: string, subject?: string}>}} params
122
+ * @returns {string}
123
+ */
124
+ function determineNextVersion({ currentVersion, commits }) {
125
+ const version = parseVersion(currentVersion);
126
+
127
+ if (!Array.isArray(commits)) {
128
+ throw new TypeError('commits must be an array');
129
+ }
130
+
131
+ let highestBump = null;
132
+
133
+ for (const commit of commits) {
134
+ const bump = getCommitBump(getCommitMessage(commit));
135
+
136
+ if (bump === 'major') {
137
+ highestBump = 'major';
138
+ break;
139
+ }
140
+
141
+ if (bump === 'minor') {
142
+ highestBump = highestBump === 'patch' ? 'minor' : 'minor';
143
+ continue;
144
+ }
145
+
146
+ if (bump === 'patch' && highestBump === null) {
147
+ highestBump = 'patch';
148
+ }
149
+ }
150
+
151
+ return bumpVersion(version, highestBump);
152
+ }
153
+
154
+ /**
155
+ * Update the version field in a package.json file.
156
+ *
157
+ * @param {{version: string, packageJsonPath: string, fs: {readFile: Function, writeFile: Function}}} params
158
+ * @returns {Promise<object>}
159
+ */
160
+ async function applyVersionBump({ version, packageJsonPath, fs }) {
161
+ parseVersion(version);
162
+
163
+ if (!packageJsonPath || typeof packageJsonPath !== 'string') {
164
+ throw new TypeError('packageJsonPath must be a string');
165
+ }
166
+
167
+ if (!fs || typeof fs.readFile !== 'function' || typeof fs.writeFile !== 'function') {
168
+ throw new TypeError('fs must provide readFile and writeFile functions');
169
+ }
170
+
171
+ const packageJson = await fs.readFile(packageJsonPath, 'utf8');
172
+ const packageData = JSON.parse(packageJson);
173
+ packageData.version = version;
174
+
175
+ await fs.writeFile(packageJsonPath, `${JSON.stringify(packageData, null, 2)}\n`, 'utf8');
176
+
177
+ return packageData;
178
+ }
179
+
180
+ module.exports = {
181
+ determineNextVersion,
182
+ applyVersionBump,
183
+ };
@@ -0,0 +1,142 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import versionBumper from './version-bumper.js';
3
+
4
+ const { determineNextVersion, applyVersionBump } = versionBumper;
5
+
6
+ describe('version-bumper', () => {
7
+ describe('determineNextVersion', () => {
8
+ it('returns the same version when no release-worthy commits are present', () => {
9
+ const nextVersion = determineNextVersion({
10
+ currentVersion: '1.2.3',
11
+ commits: ['docs: update release notes', 'chore: refresh snapshots'],
12
+ });
13
+
14
+ expect(nextVersion).toBe('1.2.3');
15
+ });
16
+
17
+ it('bumps patch for fix commits', () => {
18
+ const nextVersion = determineNextVersion({
19
+ currentVersion: '1.2.3',
20
+ commits: ['fix: handle missing session state'],
21
+ });
22
+
23
+ expect(nextVersion).toBe('1.2.4');
24
+ });
25
+
26
+ it('treats perf commits as patch releases', () => {
27
+ const nextVersion = determineNextVersion({
28
+ currentVersion: '1.2.3',
29
+ commits: ['perf: reduce websocket reconnect churn'],
30
+ });
31
+
32
+ expect(nextVersion).toBe('1.2.4');
33
+ });
34
+
35
+ it('bumps minor for feat commits', () => {
36
+ const nextVersion = determineNextVersion({
37
+ currentVersion: '1.2.3',
38
+ commits: ['feat(api): add release audit endpoint', 'fix: log errors'],
39
+ });
40
+
41
+ expect(nextVersion).toBe('1.3.0');
42
+ });
43
+
44
+ it('bumps major for bang syntax breaking changes', () => {
45
+ const nextVersion = determineNextVersion({
46
+ currentVersion: '1.2.3',
47
+ commits: ['feat!: remove deprecated release payload'],
48
+ });
49
+
50
+ expect(nextVersion).toBe('2.0.0');
51
+ });
52
+
53
+ it('bumps major for BREAKING CHANGE footers', () => {
54
+ const nextVersion = determineNextVersion({
55
+ currentVersion: '1.2.3',
56
+ commits: [
57
+ {
58
+ message: [
59
+ 'refactor: simplify release config loading',
60
+ '',
61
+ 'BREAKING CHANGE: release config now requires explicit tier names',
62
+ ].join('\n'),
63
+ },
64
+ ],
65
+ });
66
+
67
+ expect(nextVersion).toBe('2.0.0');
68
+ });
69
+
70
+ it('accepts versions with a leading v', () => {
71
+ const nextVersion = determineNextVersion({
72
+ currentVersion: 'v1.2.3',
73
+ commits: ['fix: repair preview URL generation'],
74
+ });
75
+
76
+ expect(nextVersion).toBe('1.2.4');
77
+ });
78
+
79
+ it('throws for invalid inputs', () => {
80
+ expect(() => determineNextVersion({
81
+ currentVersion: '1.2',
82
+ commits: [],
83
+ })).toThrow(/invalid semver/i);
84
+
85
+ expect(() => determineNextVersion({
86
+ currentVersion: '1.2.3',
87
+ commits: 'fix: nope',
88
+ })).toThrow(/commits must be an array/i);
89
+ });
90
+ });
91
+
92
+ describe('applyVersionBump', () => {
93
+ it('writes the bumped version back to package.json through injected fs', async () => {
94
+ const fs = {
95
+ readFile: vi.fn().mockResolvedValue('{"name":"tlc-server","version":"0.1.2"}'),
96
+ writeFile: vi.fn().mockResolvedValue(undefined),
97
+ };
98
+
99
+ const updated = await applyVersionBump({
100
+ version: '0.2.0',
101
+ packageJsonPath: '/tmp/package.json',
102
+ fs,
103
+ });
104
+
105
+ expect(updated).toEqual({
106
+ name: 'tlc-server',
107
+ version: '0.2.0',
108
+ });
109
+ expect(fs.readFile).toHaveBeenCalledWith('/tmp/package.json', 'utf8');
110
+ expect(fs.writeFile).toHaveBeenCalledWith(
111
+ '/tmp/package.json',
112
+ '{\n "name": "tlc-server",\n "version": "0.2.0"\n}\n',
113
+ 'utf8'
114
+ );
115
+ });
116
+
117
+ it('validates inputs before writing', async () => {
118
+ const fs = {
119
+ readFile: vi.fn(),
120
+ writeFile: vi.fn(),
121
+ };
122
+
123
+ await expect(applyVersionBump({
124
+ version: '1.0',
125
+ packageJsonPath: '/tmp/package.json',
126
+ fs,
127
+ })).rejects.toThrow(/invalid semver/i);
128
+
129
+ await expect(applyVersionBump({
130
+ version: '1.0.0',
131
+ packageJsonPath: '',
132
+ fs,
133
+ })).rejects.toThrow(/packageJsonPath must be a string/i);
134
+
135
+ await expect(applyVersionBump({
136
+ version: '1.0.0',
137
+ packageJsonPath: '/tmp/package.json',
138
+ fs: {},
139
+ })).rejects.toThrow(/fs must provide readFile and writeFile/i);
140
+ });
141
+ });
142
+ });
@@ -92,6 +92,7 @@ describe('routing-preamble integration', () => {
92
92
  expect(runPreamble({ commandName: 'build' }).result).toEqual({
93
93
  models: ['claude'],
94
94
  strategy: 'single',
95
+ mode: 'interactive',
95
96
  source: 'shipped-defaults',
96
97
  warnings: [],
97
98
  });
@@ -110,6 +111,7 @@ describe('routing-preamble integration', () => {
110
111
  ).toEqual({
111
112
  models: ['codex'],
112
113
  strategy: 'single',
114
+ mode: 'interactive',
113
115
  source: 'personal-config',
114
116
  warnings: [expect.stringMatching(/models/i)],
115
117
  });
@@ -128,6 +130,7 @@ describe('routing-preamble integration', () => {
128
130
  ).toEqual({
129
131
  models: ['codex', 'claude'],
130
132
  strategy: 'single',
133
+ mode: 'exec',
131
134
  source: 'personal-config',
132
135
  warnings: [],
133
136
  });
@@ -151,6 +154,7 @@ describe('routing-preamble integration', () => {
151
154
  ).toEqual({
152
155
  models: ['claude'],
153
156
  strategy: 'parallel',
157
+ mode: 'inline',
154
158
  source: 'project-override',
155
159
  warnings: [expect.stringMatching(/models/i), expect.stringMatching(/models/i)],
156
160
  });
@@ -174,6 +178,7 @@ describe('routing-preamble integration', () => {
174
178
  ).toEqual({
175
179
  models: ['claude'],
176
180
  strategy: 'single',
181
+ mode: 'inline',
177
182
  source: 'project-override',
178
183
  warnings: [],
179
184
  });
@@ -198,6 +203,7 @@ describe('routing-preamble integration', () => {
198
203
  ).toEqual({
199
204
  models: ['local-model'],
200
205
  strategy: 'single',
206
+ mode: 'interactive',
201
207
  source: 'flag-override',
202
208
  warnings: [expect.stringMatching(/models/i), expect.stringMatching(/models/i)],
203
209
  });
@@ -222,6 +228,7 @@ describe('routing-preamble integration', () => {
222
228
  ).toEqual({
223
229
  models: ['codex'],
224
230
  strategy: 'single',
231
+ mode: 'interactive',
225
232
  source: 'personal-config',
226
233
  providers,
227
234
  warnings: [expect.stringMatching(/models/i)],
@@ -237,6 +244,7 @@ describe('routing-preamble integration', () => {
237
244
  expect(result).toEqual({
238
245
  models: ['claude'],
239
246
  strategy: 'single',
247
+ mode: 'interactive',
240
248
  source: 'shipped-defaults',
241
249
  warnings: [expect.stringContaining(path.join(homeDir, '.tlc', 'config.json'))],
242
250
  });
@@ -252,6 +260,7 @@ describe('routing-preamble integration', () => {
252
260
  expect(result).toEqual({
253
261
  models: ['claude'],
254
262
  strategy: 'single',
263
+ mode: 'interactive',
255
264
  source: 'shipped-defaults',
256
265
  warnings: [expect.stringContaining(path.join(cwd, '.tlc.json'))],
257
266
  });
@@ -272,6 +281,7 @@ describe('routing-preamble integration', () => {
272
281
  ).toEqual({
273
282
  models: ['codex'],
274
283
  strategy: 'single',
284
+ mode: 'interactive',
275
285
  source: 'personal-config',
276
286
  warnings: [expect.stringMatching(/models/i)],
277
287
  });
@@ -290,6 +300,7 @@ describe('routing-preamble integration', () => {
290
300
  ).toEqual({
291
301
  models: ['codex'],
292
302
  strategy: 'parallel',
303
+ mode: 'inline',
293
304
  source: 'personal-config',
294
305
  warnings: [expect.stringMatching(/models/i)],
295
306
  });
@@ -312,6 +323,7 @@ describe('routing-preamble integration', () => {
312
323
  ).toEqual({
313
324
  models: ['codex', 'claude'],
314
325
  strategy: 'parallel',
326
+ mode: 'interactive',
315
327
  source: 'personal-config',
316
328
  warnings: [],
317
329
  });