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,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 };