tlc-claude-code 2.5.0 → 2.6.1

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 +164 -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 +29 -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,215 @@
1
+ import fs from 'fs';
2
+ import os from 'os';
3
+ import path from 'path';
4
+ import { afterEach, describe, expect, it, vi } from 'vitest';
5
+
6
+ const { dispatchToOrchestrator } = require('./session-dispatcher.js');
7
+
8
+ function makeTask(overrides = {}) {
9
+ return {
10
+ name: 'Task 1',
11
+ provider: 'codex',
12
+ prompt: 'Implement feature',
13
+ ...overrides,
14
+ };
15
+ }
16
+
17
+ describe('session-dispatcher', () => {
18
+ const tempDirs = [];
19
+
20
+ afterEach(() => {
21
+ vi.restoreAllMocks();
22
+ while (tempDirs.length > 0) {
23
+ fs.rmSync(tempDirs.pop(), { recursive: true, force: true });
24
+ }
25
+ });
26
+
27
+ function makeActiveSessionsPath() {
28
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'session-dispatcher-test-'));
29
+ tempDirs.push(tempDir);
30
+ return path.join(tempDir, '.tlc', '.active-sessions.json');
31
+ }
32
+
33
+ it('dispatches 3 tasks and creates 3 sessions', async () => {
34
+ const activeSessionsPath = makeActiveSessionsPath();
35
+ const tasks = [
36
+ makeTask({ name: 'Task 1', provider: 'claude', prompt: 'Prompt 1' }),
37
+ makeTask({ name: 'Task 2', provider: 'codex', prompt: 'Prompt 2' }),
38
+ makeTask({ name: 'Task 3', provider: 'gemini', prompt: 'Prompt 3' }),
39
+ ];
40
+ const fetch = vi
41
+ .fn()
42
+ .mockResolvedValueOnce({ ok: true, json: async () => ({ id: 'session-1' }) })
43
+ .mockResolvedValueOnce({ ok: true, json: async () => ({ id: 'session-2' }) })
44
+ .mockResolvedValueOnce({ ok: true, json: async () => ({ id: 'session-3' }) });
45
+
46
+ const result = await dispatchToOrchestrator({
47
+ orchestratorUrl: 'http://orchestrator.test/',
48
+ project: 'tlc',
49
+ tasks,
50
+ phaseBranch: 'phase/test',
51
+ activeSessionsPath,
52
+ fetch,
53
+ });
54
+
55
+ expect(fetch).toHaveBeenCalledTimes(3);
56
+ expect(fetch).toHaveBeenNthCalledWith(1, 'http://orchestrator.test/sessions', {
57
+ method: 'POST',
58
+ headers: { 'content-type': 'application/json' },
59
+ body: JSON.stringify({
60
+ project: 'tlc',
61
+ pool: 'local-tmux',
62
+ command: 'claude',
63
+ prompt: 'Prompt 1',
64
+ }),
65
+ });
66
+ expect(fetch).toHaveBeenNthCalledWith(2, 'http://orchestrator.test/sessions', {
67
+ method: 'POST',
68
+ headers: { 'content-type': 'application/json' },
69
+ body: JSON.stringify({
70
+ project: 'tlc',
71
+ pool: 'local-tmux',
72
+ command: 'codex',
73
+ prompt: 'Prompt 2',
74
+ }),
75
+ });
76
+ expect(fetch).toHaveBeenNthCalledWith(3, 'http://orchestrator.test/sessions', {
77
+ method: 'POST',
78
+ headers: { 'content-type': 'application/json' },
79
+ body: JSON.stringify({
80
+ project: 'tlc',
81
+ pool: 'local-tmux',
82
+ command: 'gemini',
83
+ prompt: 'Prompt 3',
84
+ }),
85
+ });
86
+ expect(result).toEqual({
87
+ dispatched: 3,
88
+ sessions: [
89
+ { id: 'session-1', taskName: 'Task 1' },
90
+ { id: 'session-2', taskName: 'Task 2' },
91
+ { id: 'session-3', taskName: 'Task 3' },
92
+ ],
93
+ errors: [],
94
+ });
95
+ });
96
+
97
+ it('writes session IDs to the active sessions file', async () => {
98
+ const activeSessionsPath = makeActiveSessionsPath();
99
+ const fetch = vi
100
+ .fn()
101
+ .mockResolvedValueOnce({ ok: true, json: async () => ({ id: 'session-1' }) })
102
+ .mockResolvedValueOnce({ ok: true, json: async () => ({ id: 'session-2' }) });
103
+
104
+ await dispatchToOrchestrator({
105
+ orchestratorUrl: 'http://orchestrator.test',
106
+ project: 'tlc',
107
+ tasks: [
108
+ makeTask({ name: 'Task 1', prompt: 'Prompt 1' }),
109
+ makeTask({ name: 'Task 2', prompt: 'Prompt 2' }),
110
+ ],
111
+ phaseBranch: 'phase/test',
112
+ activeSessionsPath,
113
+ fetch,
114
+ });
115
+
116
+ const written = JSON.parse(fs.readFileSync(activeSessionsPath, 'utf8'));
117
+ expect(written).toHaveLength(2);
118
+ expect(written[0]).toEqual({
119
+ sessionId: 'session-1',
120
+ taskName: 'Task 1',
121
+ startedAt: expect.any(String),
122
+ });
123
+ expect(written[1]).toEqual({
124
+ sessionId: 'session-2',
125
+ taskName: 'Task 2',
126
+ startedAt: expect.any(String),
127
+ });
128
+ });
129
+
130
+ it('returns fallback when the orchestrator is unreachable', async () => {
131
+ const activeSessionsPath = makeActiveSessionsPath();
132
+ const fetch = vi.fn().mockRejectedValue(new Error('connect ECONNREFUSED'));
133
+
134
+ const result = await dispatchToOrchestrator({
135
+ orchestratorUrl: 'http://orchestrator.test',
136
+ project: 'tlc',
137
+ tasks: [makeTask()],
138
+ phaseBranch: 'phase/test',
139
+ activeSessionsPath,
140
+ fetch,
141
+ });
142
+
143
+ expect(result).toEqual({
144
+ dispatched: 0,
145
+ errors: ['orchestrator unreachable'],
146
+ fallback: true,
147
+ });
148
+ expect(fs.existsSync(activeSessionsPath)).toBe(false);
149
+ });
150
+
151
+ it('reports errors for partial failure', async () => {
152
+ const activeSessionsPath = makeActiveSessionsPath();
153
+ const fetch = vi
154
+ .fn()
155
+ .mockResolvedValueOnce({ ok: true, json: async () => ({ id: 'session-1' }) })
156
+ .mockResolvedValueOnce({ ok: false, status: 500, json: async () => ({}) })
157
+ .mockResolvedValueOnce({ ok: true, json: async () => ({ id: 'session-3' }) });
158
+
159
+ const result = await dispatchToOrchestrator({
160
+ orchestratorUrl: 'http://orchestrator.test',
161
+ project: 'tlc',
162
+ tasks: [
163
+ makeTask({ name: 'Task 1', prompt: 'Prompt 1' }),
164
+ makeTask({ name: 'Task 2', prompt: 'Prompt 2' }),
165
+ makeTask({ name: 'Task 3', prompt: 'Prompt 3' }),
166
+ ],
167
+ phaseBranch: 'phase/test',
168
+ activeSessionsPath,
169
+ fetch,
170
+ });
171
+
172
+ expect(result).toEqual({
173
+ dispatched: 2,
174
+ sessions: [
175
+ { id: 'session-1', taskName: 'Task 1' },
176
+ { id: 'session-3', taskName: 'Task 3' },
177
+ ],
178
+ errors: ['Task 2: 500'],
179
+ });
180
+ expect(JSON.parse(fs.readFileSync(activeSessionsPath, 'utf8'))).toEqual([
181
+ {
182
+ sessionId: 'session-1',
183
+ taskName: 'Task 1',
184
+ startedAt: expect.any(String),
185
+ },
186
+ {
187
+ sessionId: 'session-3',
188
+ taskName: 'Task 3',
189
+ startedAt: expect.any(String),
190
+ },
191
+ ]);
192
+ });
193
+
194
+ it('returns dispatched 0 for an empty task list', async () => {
195
+ const activeSessionsPath = makeActiveSessionsPath();
196
+ const fetch = vi.fn();
197
+
198
+ const result = await dispatchToOrchestrator({
199
+ orchestratorUrl: 'http://orchestrator.test',
200
+ project: 'tlc',
201
+ tasks: [],
202
+ phaseBranch: 'phase/test',
203
+ activeSessionsPath,
204
+ fetch,
205
+ });
206
+
207
+ expect(result).toEqual({
208
+ dispatched: 0,
209
+ sessions: [],
210
+ errors: [],
211
+ });
212
+ expect(fetch).not.toHaveBeenCalled();
213
+ expect(fs.existsSync(activeSessionsPath)).toBe(false);
214
+ });
215
+ });
@@ -0,0 +1,147 @@
1
+ /**
2
+ * Format an elapsed duration from a session creation time.
3
+ * @param {string|Date|number} createdAt
4
+ * @returns {string}
5
+ */
6
+ function formatElapsed(createdAt) {
7
+ const startedAt = new Date(createdAt).getTime();
8
+ if (Number.isNaN(startedAt)) {
9
+ return '-';
10
+ }
11
+
12
+ const diffMs = Math.max(0, Date.now() - startedAt);
13
+ const totalMinutes = Math.floor(diffMs / 60000);
14
+ const hours = Math.floor(totalMinutes / 60);
15
+ const minutes = totalMinutes % 60;
16
+
17
+ if (hours > 0) {
18
+ return `${hours}h ${minutes}m`;
19
+ }
20
+
21
+ return `${minutes}m`;
22
+ }
23
+
24
+ /**
25
+ * Convert a status string into title case.
26
+ * @param {string} status
27
+ * @returns {string}
28
+ */
29
+ function titleCaseStatus(status) {
30
+ const value = String(status || '').trim();
31
+ if (!value) return 'Completed';
32
+ return value.charAt(0).toUpperCase() + value.slice(1);
33
+ }
34
+
35
+ /**
36
+ * Build a summary for a completed session.
37
+ * @param {object} session
38
+ * @returns {string}
39
+ */
40
+ function buildSummary(session) {
41
+ if (session.summary) {
42
+ return session.summary;
43
+ }
44
+
45
+ if (session.command) {
46
+ return `${titleCaseStatus(session.status)}: ${session.command}`;
47
+ }
48
+
49
+ return titleCaseStatus(session.status);
50
+ }
51
+
52
+ /**
53
+ * Format rows into an aligned CLI table.
54
+ * @param {string[]} headers
55
+ * @param {string[][]} rows
56
+ * @returns {string}
57
+ */
58
+ function formatTable(headers, rows) {
59
+ const widths = headers.map((header, index) => {
60
+ const rowWidth = rows.reduce((max, row) => Math.max(max, String(row[index] || '').length), 0);
61
+ return Math.max(header.length, rowWidth);
62
+ });
63
+
64
+ const formatRow = (row) => row.map((cell, index) => String(cell || '').padEnd(widths[index])).join(' ');
65
+
66
+ return [
67
+ formatRow(headers),
68
+ widths.map((width) => '-'.repeat(width)).join(' '),
69
+ ...rows.map(formatRow),
70
+ ].join('\n');
71
+ }
72
+
73
+ /**
74
+ * Format attach hints for a set of sessions.
75
+ * @param {object[]} sessions
76
+ * @returns {string}
77
+ */
78
+ function formatAttachHints(sessions) {
79
+ return sessions
80
+ .map((session) => `Attach: tlc-core attach ${session.id}`)
81
+ .join('\n');
82
+ }
83
+
84
+ /**
85
+ * Format orchestration session status for CLI output.
86
+ * @param {{ sessions?: object[] }} input
87
+ * @returns {string|null}
88
+ */
89
+ function formatSessionStatus({ sessions } = {}) {
90
+ if (!Array.isArray(sessions) || sessions.length === 0) {
91
+ return null;
92
+ }
93
+
94
+ const running = [];
95
+ const completed = [];
96
+
97
+ for (const session of sessions) {
98
+ if (String(session.status).toLowerCase() === 'running') {
99
+ running.push(session);
100
+ } else {
101
+ completed.push(session);
102
+ }
103
+ }
104
+
105
+ const sections = [];
106
+
107
+ if (running.length > 0) {
108
+ sections.push([
109
+ 'Running Sessions',
110
+ formatTable(
111
+ ['ID', 'Project', 'Status', 'Elapsed', 'Task'],
112
+ running.map((session) => [
113
+ session.id || '-',
114
+ session.project || '-',
115
+ session.status || '-',
116
+ formatElapsed(session.created_at),
117
+ session.command || '-',
118
+ ])
119
+ ),
120
+ formatAttachHints(running),
121
+ ].join('\n'));
122
+ }
123
+
124
+ if (completed.length > 0) {
125
+ sections.push([
126
+ 'Completed Sessions',
127
+ formatTable(
128
+ ['ID', 'Project', 'Status', 'Summary'],
129
+ completed.map((session) => [
130
+ session.id || '-',
131
+ session.project || '-',
132
+ session.status || '-',
133
+ buildSummary(session),
134
+ ])
135
+ ),
136
+ formatAttachHints(completed),
137
+ ].join('\n'));
138
+ }
139
+
140
+ return sections.join('\n\n');
141
+ }
142
+
143
+ module.exports = {
144
+ formatSessionStatus,
145
+ formatElapsed,
146
+ buildSummary,
147
+ };
@@ -0,0 +1,130 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+
3
+ const { formatSessionStatus } = require('./session-status.js');
4
+
5
+ describe('session-status', () => {
6
+ beforeEach(() => {
7
+ vi.useFakeTimers();
8
+ vi.setSystemTime(new Date('2026-03-30T12:00:00Z'));
9
+ });
10
+
11
+ afterEach(() => {
12
+ vi.useRealTimers();
13
+ });
14
+
15
+ it('formats running sessions with elapsed time', () => {
16
+ const output = formatSessionStatus({
17
+ sessions: [
18
+ {
19
+ id: 'sess-101',
20
+ project: 'alpha',
21
+ status: 'running',
22
+ created_at: '2026-03-30T11:15:00Z',
23
+ command: 'Implement API',
24
+ },
25
+ ],
26
+ });
27
+
28
+ expect(output).toContain('Running Sessions');
29
+ expect(output).toContain('sess-101');
30
+ expect(output).toContain('alpha');
31
+ expect(output).toContain('Implement API');
32
+ expect(output).toContain('45m');
33
+ expect(output).toContain('Attach: tlc-core attach sess-101');
34
+ });
35
+
36
+ it('formats completed sessions with summary', () => {
37
+ const output = formatSessionStatus({
38
+ sessions: [
39
+ {
40
+ id: 'sess-202',
41
+ project: 'beta',
42
+ status: 'completed',
43
+ created_at: '2026-03-30T10:00:00Z',
44
+ command: 'Add tests',
45
+ },
46
+ ],
47
+ });
48
+
49
+ expect(output).toContain('Completed Sessions');
50
+ expect(output).toContain('sess-202');
51
+ expect(output).toContain('beta');
52
+ expect(output).toContain('Completed: Add tests');
53
+ expect(output).toContain('Attach: tlc-core attach sess-202');
54
+ });
55
+
56
+ it('mixes running and completed sections in one output', () => {
57
+ const output = formatSessionStatus({
58
+ sessions: [
59
+ {
60
+ id: 'sess-run',
61
+ project: 'gamma',
62
+ status: 'running',
63
+ created_at: '2026-03-30T11:50:00Z',
64
+ command: 'Refactor worker',
65
+ },
66
+ {
67
+ id: 'sess-done',
68
+ project: 'delta',
69
+ status: 'failed',
70
+ created_at: '2026-03-30T10:30:00Z',
71
+ command: 'Fix flaky test',
72
+ },
73
+ ],
74
+ });
75
+
76
+ expect(output).toContain('Running Sessions');
77
+ expect(output).toContain('Completed Sessions');
78
+ expect(output).toContain('10m');
79
+ expect(output).toContain('Failed: Fix flaky test');
80
+ });
81
+
82
+ it('returns null for empty sessions', () => {
83
+ expect(formatSessionStatus({ sessions: [] })).toBeNull();
84
+ expect(formatSessionStatus({ sessions: null })).toBeNull();
85
+ });
86
+
87
+ it('formats aligned tables', () => {
88
+ const output = formatSessionStatus({
89
+ sessions: [
90
+ {
91
+ id: 's-1',
92
+ project: 'short',
93
+ status: 'running',
94
+ created_at: '2026-03-30T11:00:00Z',
95
+ command: 'Task A',
96
+ },
97
+ {
98
+ id: 'session-222',
99
+ project: 'much-longer-project',
100
+ status: 'running',
101
+ created_at: '2026-03-30T11:30:00Z',
102
+ command: 'Task B',
103
+ },
104
+ ],
105
+ });
106
+
107
+ const lines = output.split('\n');
108
+ const header = lines.find((line) => line.startsWith('ID'));
109
+ const firstRow = lines.find((line) => line.startsWith('s-1'));
110
+ const secondRow = lines.find((line) => line.startsWith('session-222'));
111
+
112
+ expect(header).toBeTruthy();
113
+ expect(firstRow).toBeTruthy();
114
+ expect(secondRow).toBeTruthy();
115
+
116
+ const projectStart = header.indexOf('Project');
117
+ const statusStart = header.indexOf('Status');
118
+ const elapsedStart = header.indexOf('Elapsed');
119
+ const taskStart = header.indexOf('Task');
120
+
121
+ expect(firstRow.slice(projectStart, projectStart + 'short'.length)).toBe('short');
122
+ expect(secondRow.slice(projectStart, projectStart + 'much-longer-project'.length)).toBe('much-longer-project');
123
+ expect(firstRow.slice(statusStart, statusStart + 'running'.length)).toBe('running');
124
+ expect(secondRow.slice(statusStart, statusStart + 'running'.length)).toBe('running');
125
+ expect(firstRow.slice(elapsedStart, elapsedStart + '1h 0m'.length)).toBe('1h 0m');
126
+ expect(secondRow.slice(elapsedStart, elapsedStart + '30m'.length)).toBe('30m');
127
+ expect(firstRow.slice(taskStart, taskStart + 'Task A'.length)).toBe('Task A');
128
+ expect(secondRow.slice(taskStart, taskStart + 'Task B'.length)).toBe('Task B');
129
+ });
130
+ });
@@ -0,0 +1,24 @@
1
+ const DOCKERFILE_ADDITIONS = [
2
+ 'RUN apt-get update && apt-get install -y --no-install-recommends gh openssh-client && rm -rf /var/lib/apt/lists/*',
3
+ ].join('\n');
4
+
5
+ const ENTRYPOINT_ADDITIONS = [
6
+ 'if [ -n "$GH_TOKEN" ]; then',
7
+ ' printf "%s" "$GH_TOKEN" | gh auth login --with-token',
8
+ 'fi',
9
+ 'git config --global user.name "TLC Agent Runner"',
10
+ 'git config --global user.email "agent-runner@tlc.local"',
11
+ ].join('\n');
12
+
13
+ export function getDockerfileAdditions() {
14
+ return DOCKERFILE_ADDITIONS;
15
+ }
16
+
17
+ export function getEntrypointAdditions() {
18
+ return ENTRYPOINT_ADDITIONS;
19
+ }
20
+
21
+ export default {
22
+ getDockerfileAdditions,
23
+ getEntrypointAdditions,
24
+ };
@@ -0,0 +1,22 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { getDockerfileAdditions, getEntrypointAdditions } from './agent-runner-updates.js';
3
+
4
+ describe('agent-runner-updates', () => {
5
+ it('returns Dockerfile additions for gh CLI and openssh-client', () => {
6
+ expect(getDockerfileAdditions()).toBe(
7
+ 'RUN apt-get update && apt-get install -y --no-install-recommends gh openssh-client && rm -rf /var/lib/apt/lists/*'
8
+ );
9
+ });
10
+
11
+ it('returns entrypoint additions for GH_TOKEN auth and git identity config', () => {
12
+ expect(getEntrypointAdditions()).toBe(
13
+ [
14
+ 'if [ -n "$GH_TOKEN" ]; then',
15
+ ' printf "%s" "$GH_TOKEN" | gh auth login --with-token',
16
+ 'fi',
17
+ 'git config --global user.name "TLC Agent Runner"',
18
+ 'git config --global user.email "agent-runner@tlc.local"',
19
+ ].join('\n')
20
+ );
21
+ });
22
+ });
@@ -0,0 +1,142 @@
1
+ 'use strict';
2
+
3
+ const DEFAULT_TITLE = '# Changelog';
4
+
5
+ const GROUPS = [
6
+ { key: 'feat', heading: 'Features', match: /^feat(?:\(.+\))?!?:\s*/i },
7
+ { key: 'fix', heading: 'Fixes', match: /^fix(?:\(.+\))?!?:\s*/i },
8
+ { key: 'perf', heading: 'Performance', match: /^perf(?:\(.+\))?!?:\s*/i },
9
+ { key: 'refactor', heading: 'Refactors', match: /^refactor(?:\(.+\))?!?:\s*/i },
10
+ { key: 'docs', heading: 'Documentation', match: /^docs(?:\(.+\))?!?:\s*/i },
11
+ { key: 'test', heading: 'Tests', match: /^test(?:\(.+\))?!?:\s*/i },
12
+ { key: 'build', heading: 'Build', match: /^build(?:\(.+\))?!?:\s*/i },
13
+ { key: 'ci', heading: 'CI', match: /^ci(?:\(.+\))?!?:\s*/i },
14
+ { key: 'chore', heading: 'Chores', match: /^chore(?:\(.+\))?!?:\s*/i },
15
+ ];
16
+
17
+ function normalizeCommit(commit) {
18
+ if (!commit) {
19
+ return null;
20
+ }
21
+
22
+ if (typeof commit === 'string') {
23
+ return {
24
+ sha: '',
25
+ message: commit.trim(),
26
+ };
27
+ }
28
+
29
+ if (typeof commit.message !== 'string') {
30
+ return null;
31
+ }
32
+
33
+ return {
34
+ sha: typeof commit.sha === 'string' ? commit.sha : '',
35
+ message: commit.message.trim(),
36
+ };
37
+ }
38
+
39
+ function stripPrefix(message, group) {
40
+ if (!group) {
41
+ return message;
42
+ }
43
+
44
+ return message.replace(group.match, '').trim();
45
+ }
46
+
47
+ function formatCommit(commit, group) {
48
+ const cleanedMessage = stripPrefix(commit.message, group);
49
+ const shortSha = commit.sha ? commit.sha.slice(0, 7) : '';
50
+ return shortSha
51
+ ? `- ${cleanedMessage} (\`${shortSha}\`)`
52
+ : `- ${cleanedMessage}`;
53
+ }
54
+
55
+ function buildReleaseHeading(version, date) {
56
+ const versionLabel = version || 'Unreleased';
57
+ return date ? `## ${versionLabel} - ${date}` : `## ${versionLabel}`;
58
+ }
59
+
60
+ function generateChangelog({ commits = [], version, date } = {}) {
61
+ const normalizedCommits = commits
62
+ .map(normalizeCommit)
63
+ .filter(commit => commit && commit.message);
64
+
65
+ const sections = [];
66
+ const remainingCommits = [...normalizedCommits];
67
+
68
+ for (const group of GROUPS) {
69
+ const matches = remainingCommits.filter(commit => group.match.test(commit.message));
70
+ if (matches.length === 0) {
71
+ continue;
72
+ }
73
+
74
+ sections.push(`### ${group.heading}`);
75
+ sections.push(...matches.map(commit => formatCommit(commit, group)));
76
+ sections.push('');
77
+
78
+ for (const match of matches) {
79
+ const index = remainingCommits.indexOf(match);
80
+ if (index >= 0) {
81
+ remainingCommits.splice(index, 1);
82
+ }
83
+ }
84
+ }
85
+
86
+ if (remainingCommits.length > 0) {
87
+ sections.push('### Other');
88
+ sections.push(...remainingCommits.map(commit => formatCommit(commit)));
89
+ sections.push('');
90
+ }
91
+
92
+ const lines = [buildReleaseHeading(version, date), ''];
93
+ if (sections.length === 0) {
94
+ lines.push('- No changes recorded');
95
+ } else {
96
+ lines.push(...sections);
97
+ }
98
+
99
+ return lines.join('\n').trimEnd();
100
+ }
101
+
102
+ function prependToChangelog({ changelogPath, content, fs }) {
103
+ if (!changelogPath) {
104
+ throw new Error('changelogPath is required');
105
+ }
106
+
107
+ if (!fs || typeof fs.readFileSync !== 'function' || typeof fs.writeFileSync !== 'function') {
108
+ throw new Error('fs with readFileSync and writeFileSync is required');
109
+ }
110
+
111
+ const normalizedContent = String(content || '').trim();
112
+ if (!normalizedContent) {
113
+ throw new Error('content is required');
114
+ }
115
+
116
+ let existing = '';
117
+ try {
118
+ existing = fs.readFileSync(changelogPath, 'utf8');
119
+ } catch (error) {
120
+ if (!error || error.code !== 'ENOENT') {
121
+ throw error;
122
+ }
123
+ }
124
+
125
+ let nextContent;
126
+ if (!existing) {
127
+ nextContent = `${DEFAULT_TITLE}\n\n${normalizedContent}\n`;
128
+ } else if (existing.startsWith(`${DEFAULT_TITLE}\n`)) {
129
+ const remainder = existing.slice(DEFAULT_TITLE.length).replace(/^\s*/, '');
130
+ nextContent = `${DEFAULT_TITLE}\n\n${normalizedContent}${remainder ? `\n\n${remainder.trimStart()}` : '\n'}`;
131
+ } else {
132
+ nextContent = `${normalizedContent}\n\n${existing.trimStart()}`;
133
+ }
134
+
135
+ fs.writeFileSync(changelogPath, nextContent, 'utf8');
136
+ return nextContent;
137
+ }
138
+
139
+ module.exports = {
140
+ generateChangelog,
141
+ prependToChangelog,
142
+ };