tlc-claude-code 2.2.1 → 2.3.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.
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Careful Patterns Module
3
+ *
4
+ * Destructive command detection patterns for /tlc:careful
5
+ * and path scope checking for /tlc:freeze.
6
+ *
7
+ * @module careful-patterns
8
+ */
9
+
10
+ const path = require('path');
11
+
12
+ /**
13
+ * Destructive command patterns with their metadata.
14
+ * Each entry: { regex, reason, pattern }
15
+ * @type {Array<{regex: RegExp, reason: string, pattern: string}>}
16
+ */
17
+ const DESTRUCTIVE_PATTERNS = [
18
+ {
19
+ regex: /\bgit\s+push\s+(?:.*\s+)?--force\b/,
20
+ reason: 'Force push',
21
+ pattern: 'git push --force',
22
+ },
23
+ {
24
+ regex: /\bgit\s+push\s+(?:.*\s+)?-f\b/,
25
+ reason: 'Force push',
26
+ pattern: 'git push -f',
27
+ },
28
+ {
29
+ regex: /\bgit\s+reset\s+--hard\b/,
30
+ reason: 'Hard reset',
31
+ pattern: 'git reset --hard',
32
+ },
33
+ {
34
+ regex: /\brm\s+-rf\b/,
35
+ reason: 'Recursive force delete',
36
+ pattern: 'rm -rf',
37
+ },
38
+ {
39
+ regex: /\bdrop\s+table\b/i,
40
+ reason: 'Drop table',
41
+ pattern: 'DROP TABLE',
42
+ },
43
+ {
44
+ regex: /\bdrop\s+database\b/i,
45
+ reason: 'Drop database',
46
+ pattern: 'DROP DATABASE',
47
+ },
48
+ {
49
+ regex: /\btruncate\s+table\b/i,
50
+ reason: 'Truncate table',
51
+ pattern: 'TRUNCATE TABLE',
52
+ },
53
+ {
54
+ regex: /\bgit\s+clean\s+-f/,
55
+ reason: 'Clean untracked files',
56
+ pattern: 'git clean -f',
57
+ },
58
+ ];
59
+
60
+ /**
61
+ * Check if a shell command matches destructive patterns.
62
+ *
63
+ * Checks against known dangerous operations like force push,
64
+ * hard reset, recursive delete, and destructive SQL statements.
65
+ * SQL patterns are matched case-insensitively.
66
+ *
67
+ * @param {string} command - Shell command string to check
68
+ * @returns {{ destructive: boolean, reason?: string, pattern?: string }}
69
+ */
70
+ function isDestructive(command) {
71
+ if (!command || typeof command !== 'string') {
72
+ return { destructive: false };
73
+ }
74
+
75
+ // Check DELETE FROM without WHERE (special case: only destructive without WHERE)
76
+ // Don't early-return on safe DELETE — continue checking for other destructive patterns
77
+ const deleteMatch = command.match(/\bdelete\s+from\b/i);
78
+ if (deleteMatch) {
79
+ const hasWhere = /\bwhere\b/i.test(command);
80
+ if (!hasWhere) {
81
+ return {
82
+ destructive: true,
83
+ reason: 'Delete without WHERE',
84
+ pattern: 'DELETE FROM',
85
+ };
86
+ }
87
+ // DELETE FROM ... WHERE is safe, but continue scanning for other patterns
88
+ // (e.g., "DELETE FROM users WHERE id=1; DROP TABLE sessions")
89
+ }
90
+
91
+ // Check all other patterns
92
+ for (const entry of DESTRUCTIVE_PATTERNS) {
93
+ if (entry.regex.test(command)) {
94
+ return {
95
+ destructive: true,
96
+ reason: entry.reason,
97
+ pattern: entry.pattern,
98
+ };
99
+ }
100
+ }
101
+
102
+ return { destructive: false };
103
+ }
104
+
105
+ /**
106
+ * Check if a file path is within an allowed scope directory.
107
+ *
108
+ * Resolves parent traversal (../) before checking containment.
109
+ * Both paths are normalized to prevent bypass via trailing slashes
110
+ * or prefix collisions (e.g., src/auth vs src/authorization).
111
+ *
112
+ * @param {string} filePath - File path to check
113
+ * @param {string} scopeDir - Allowed scope directory
114
+ * @returns {boolean}
115
+ */
116
+ function isInScope(filePath, scopeDir) {
117
+ if (!filePath || !scopeDir) {
118
+ return false;
119
+ }
120
+ if (typeof filePath !== 'string' || typeof scopeDir !== 'string') {
121
+ return false;
122
+ }
123
+
124
+ // Normalize both paths to resolve ../ and remove trailing slashes
125
+ const normalizedFile = path.normalize(filePath);
126
+ const normalizedScope = path.normalize(scopeDir);
127
+
128
+ // Exact match
129
+ if (normalizedFile === normalizedScope) {
130
+ return true;
131
+ }
132
+
133
+ // File must be under scope directory (with path separator to prevent prefix collisions)
134
+ const scopePrefix = normalizedScope.endsWith(path.sep)
135
+ ? normalizedScope
136
+ : normalizedScope + path.sep;
137
+
138
+ return normalizedFile.startsWith(scopePrefix);
139
+ }
140
+
141
+
142
+ module.exports = { isDestructive, isInScope };
@@ -0,0 +1,164 @@
1
+ /**
2
+ * Careful Patterns Tests - Phase 89 Task 6
3
+ *
4
+ * Destructive command detection and path scope checking
5
+ */
6
+
7
+ import { describe, it, expect } from 'vitest';
8
+
9
+ import { isDestructive, isInScope } from './careful-patterns.js';
10
+
11
+ describe('careful-patterns', () => {
12
+ describe('isDestructive', () => {
13
+ it('detects git push --force as destructive', () => {
14
+ const result = isDestructive('git push --force origin main');
15
+ expect(result.destructive).toBe(true);
16
+ expect(result.reason).toBe('Force push');
17
+ expect(result.pattern).toBe('git push --force');
18
+ });
19
+
20
+ it('detects git push -f as destructive', () => {
21
+ const result = isDestructive('git push -f origin main');
22
+ expect(result.destructive).toBe(true);
23
+ expect(result.reason).toBe('Force push');
24
+ });
25
+
26
+ it('allows normal git push', () => {
27
+ const result = isDestructive('git push origin feature');
28
+ expect(result.destructive).toBe(false);
29
+ expect(result.reason).toBeUndefined();
30
+ expect(result.pattern).toBeUndefined();
31
+ });
32
+
33
+ it('detects git reset --hard as destructive', () => {
34
+ const result = isDestructive('git reset --hard HEAD~3');
35
+ expect(result.destructive).toBe(true);
36
+ expect(result.reason).toBe('Hard reset');
37
+ expect(result.pattern).toBe('git reset --hard');
38
+ });
39
+
40
+ it('allows git reset --soft', () => {
41
+ const result = isDestructive('git reset --soft HEAD~1');
42
+ expect(result.destructive).toBe(false);
43
+ });
44
+
45
+ it('detects rm -rf as destructive', () => {
46
+ const result = isDestructive('rm -rf /');
47
+ expect(result.destructive).toBe(true);
48
+ expect(result.reason).toBe('Recursive force delete');
49
+ expect(result.pattern).toBe('rm -rf');
50
+ });
51
+
52
+ it('allows simple rm', () => {
53
+ const result = isDestructive('rm file.txt');
54
+ expect(result.destructive).toBe(false);
55
+ });
56
+
57
+ it('detects DROP TABLE as destructive', () => {
58
+ const result = isDestructive('DROP TABLE users');
59
+ expect(result.destructive).toBe(true);
60
+ expect(result.reason).toBe('Drop table');
61
+ expect(result.pattern).toMatch(/drop table/i);
62
+ });
63
+
64
+ it('detects DELETE FROM without WHERE as destructive', () => {
65
+ const result = isDestructive('DELETE FROM users');
66
+ expect(result.destructive).toBe(true);
67
+ expect(result.reason).toBe('Delete without WHERE');
68
+ });
69
+
70
+ it('allows DELETE FROM with WHERE', () => {
71
+ const result = isDestructive('DELETE FROM users WHERE id = 1');
72
+ expect(result.destructive).toBe(false);
73
+ });
74
+
75
+ it('detects DROP after safe DELETE in multi-statement command', () => {
76
+ const result = isDestructive('DELETE FROM users WHERE id = 1; DROP TABLE sessions');
77
+ expect(result.destructive).toBe(true);
78
+ expect(result.reason).toBe('Drop table');
79
+ });
80
+
81
+ it('detects TRUNCATE TABLE as destructive', () => {
82
+ const result = isDestructive('TRUNCATE TABLE sessions');
83
+ expect(result.destructive).toBe(true);
84
+ expect(result.reason).toBe('Truncate table');
85
+ });
86
+
87
+ it('detects git clean -fd as destructive', () => {
88
+ const result = isDestructive('git clean -fd');
89
+ expect(result.destructive).toBe(true);
90
+ expect(result.reason).toBe('Clean untracked files');
91
+ expect(result.pattern).toBe('git clean -f');
92
+ });
93
+
94
+ it('allows npm install', () => {
95
+ const result = isDestructive('npm install express');
96
+ expect(result.destructive).toBe(false);
97
+ });
98
+
99
+ it('detects DROP DATABASE as destructive', () => {
100
+ const result = isDestructive('DROP DATABASE production');
101
+ expect(result.destructive).toBe(true);
102
+ expect(result.reason).toBe('Drop database');
103
+ });
104
+
105
+ it('handles SQL commands case-insensitively', () => {
106
+ expect(isDestructive('drop table users').destructive).toBe(true);
107
+ expect(isDestructive('Drop Table users').destructive).toBe(true);
108
+ expect(isDestructive('DELETE from users').destructive).toBe(true);
109
+ expect(isDestructive('truncate TABLE sessions').destructive).toBe(true);
110
+ });
111
+
112
+ it('returns destructive:false for empty string', () => {
113
+ const result = isDestructive('');
114
+ expect(result.destructive).toBe(false);
115
+ });
116
+
117
+ it('returns destructive:false for null/undefined', () => {
118
+ expect(isDestructive(null).destructive).toBe(false);
119
+ expect(isDestructive(undefined).destructive).toBe(false);
120
+ });
121
+ });
122
+
123
+ describe('isInScope', () => {
124
+ it('returns true for file within scope directory', () => {
125
+ expect(isInScope('src/auth/login.ts', 'src/auth')).toBe(true);
126
+ });
127
+
128
+ it('returns false for file outside scope directory', () => {
129
+ expect(isInScope('src/user/user.ts', 'src/auth')).toBe(false);
130
+ });
131
+
132
+ it('returns false for parent traversal attempts', () => {
133
+ expect(isInScope('src/auth/../user/user.ts', 'src/auth')).toBe(false);
134
+ });
135
+
136
+ it('returns true for deeply nested files within scope', () => {
137
+ expect(isInScope('src/auth/deep/nested/file.ts', 'src/auth')).toBe(true);
138
+ });
139
+
140
+ it('returns true for exact directory match', () => {
141
+ expect(isInScope('src/auth', 'src/auth')).toBe(true);
142
+ });
143
+
144
+ it('returns false when file path starts with scope but is a different dir', () => {
145
+ // src/authorization is not inside src/auth
146
+ expect(isInScope('src/authorization/file.ts', 'src/auth')).toBe(false);
147
+ });
148
+
149
+ it('handles trailing slashes consistently', () => {
150
+ expect(isInScope('src/auth/file.ts', 'src/auth/')).toBe(true);
151
+ expect(isInScope('src/auth/file.ts', 'src/auth')).toBe(true);
152
+ });
153
+
154
+ it('returns false for empty inputs', () => {
155
+ expect(isInScope('', 'src/auth')).toBe(false);
156
+ expect(isInScope('src/auth/file.ts', '')).toBe(false);
157
+ });
158
+
159
+ it('returns false for null/undefined inputs', () => {
160
+ expect(isInScope(null, 'src/auth')).toBe(false);
161
+ expect(isInScope('src/auth/file.ts', null)).toBe(false);
162
+ });
163
+ });
164
+ });
@@ -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 };
@@ -0,0 +1,195 @@
1
+ /**
2
+ * Field Report Tests
3
+ *
4
+ * Tests for agent self-rating field report module.
5
+ * Reports are filed when quality is below threshold.
6
+ */
7
+
8
+ import { describe, it, expect } from 'vitest';
9
+
10
+ import {
11
+ shouldFileReport,
12
+ createFieldReport,
13
+ formatReportEntry,
14
+ appendReport,
15
+ } from './field-report.js';
16
+
17
+ const FIXED_DATE = '2026-03-26T12:00:00.000Z';
18
+ const nowFn = () => new Date(FIXED_DATE);
19
+
20
+ describe('field-report', () => {
21
+ describe('shouldFileReport', () => {
22
+ it('returns false for rating 10', () => {
23
+ expect(shouldFileReport(10)).toBe(false);
24
+ });
25
+
26
+ it('returns false for rating 9', () => {
27
+ expect(shouldFileReport(9)).toBe(false);
28
+ });
29
+
30
+ it('returns false for rating 8', () => {
31
+ expect(shouldFileReport(8)).toBe(false);
32
+ });
33
+
34
+ it('returns true for rating 7', () => {
35
+ expect(shouldFileReport(7)).toBe(true);
36
+ });
37
+
38
+ it('returns true for rating 0', () => {
39
+ expect(shouldFileReport(0)).toBe(true);
40
+ });
41
+
42
+ it('returns true for rating 1', () => {
43
+ expect(shouldFileReport(1)).toBe(true);
44
+ });
45
+
46
+ it('returns true for rating 5', () => {
47
+ expect(shouldFileReport(5)).toBe(true);
48
+ });
49
+ });
50
+
51
+ describe('createFieldReport', () => {
52
+ it('returns markdown with all fields', () => {
53
+ const report = createFieldReport({
54
+ skill: 'tlc:build',
55
+ rating: 5,
56
+ issue: 'Tests were flaky',
57
+ suggestion: 'Add retry logic',
58
+ now: nowFn,
59
+ });
60
+
61
+ expect(report).toContain('tlc:build');
62
+ expect(report).toContain('5/10');
63
+ expect(report).toContain('Tests were flaky');
64
+ expect(report).toContain('Add retry logic');
65
+ expect(report).toContain('**Suggestion:**');
66
+ });
67
+
68
+ it('includes CRITICAL marker for rating 0', () => {
69
+ const report = createFieldReport({
70
+ skill: 'tlc:review',
71
+ rating: 0,
72
+ issue: 'Complete failure',
73
+ suggestion: 'Start over',
74
+ now: nowFn,
75
+ });
76
+
77
+ expect(report).toContain('CRITICAL');
78
+ expect(report).toContain('0/10');
79
+ });
80
+
81
+ it('does not include CRITICAL marker for rating above 0', () => {
82
+ const report = createFieldReport({
83
+ skill: 'tlc:build',
84
+ rating: 3,
85
+ issue: 'Poor quality',
86
+ suggestion: 'Improve',
87
+ now: nowFn,
88
+ });
89
+
90
+ expect(report).not.toContain('CRITICAL');
91
+ });
92
+
93
+ it('includes ISO date string', () => {
94
+ const report = createFieldReport({
95
+ skill: 'tlc:build',
96
+ rating: 5,
97
+ issue: 'Flaky',
98
+ suggestion: 'Fix it',
99
+ now: nowFn,
100
+ });
101
+
102
+ expect(report).toContain('2026-03-26');
103
+ });
104
+
105
+ it('follows expected heading format', () => {
106
+ const report = createFieldReport({
107
+ skill: 'tlc:build',
108
+ rating: 5,
109
+ issue: 'Tests were flaky',
110
+ suggestion: 'Add retry logic',
111
+ now: nowFn,
112
+ });
113
+
114
+ expect(report).toMatch(
115
+ /^## 2026-03-26T12:00:00\.000Z tlc:build 5\/10 — Tests were flaky/
116
+ );
117
+ });
118
+ });
119
+
120
+ describe('formatReportEntry', () => {
121
+ it('returns properly formatted markdown section', () => {
122
+ const report = createFieldReport({
123
+ skill: 'tlc:build',
124
+ rating: 4,
125
+ issue: 'Slow tests',
126
+ suggestion: 'Parallelize',
127
+ now: nowFn,
128
+ });
129
+
130
+ const entry = formatReportEntry(report);
131
+
132
+ expect(entry).toContain('## 2026-03-26');
133
+ expect(entry).toContain('tlc:build');
134
+ expect(entry).toContain('4/10');
135
+ expect(entry).toContain('Slow tests');
136
+ expect(entry).toContain('**Suggestion:** Parallelize');
137
+ // Entry should end with a newline
138
+ expect(entry.endsWith('\n')).toBe(true);
139
+ });
140
+
141
+ it('returns the report unchanged if already formatted', () => {
142
+ const report = createFieldReport({
143
+ skill: 'tlc:build',
144
+ rating: 6,
145
+ issue: 'Minor issue',
146
+ suggestion: 'Tweak config',
147
+ now: nowFn,
148
+ });
149
+
150
+ const entry = formatReportEntry(report);
151
+ // formatReportEntry should ensure trailing newline
152
+ expect(entry).toBe(report.trimEnd() + '\n');
153
+ });
154
+ });
155
+
156
+ describe('appendReport', () => {
157
+ it('appends to existing content with separator', () => {
158
+ const report = createFieldReport({
159
+ skill: 'tlc:build',
160
+ rating: 5,
161
+ issue: 'Flaky tests',
162
+ suggestion: 'Add retries',
163
+ now: nowFn,
164
+ });
165
+
166
+ const result = appendReport('existing content', report);
167
+
168
+ expect(result).toContain('existing content');
169
+ expect(result).toContain(report.trim());
170
+ // Existing content comes first
171
+ expect(result.indexOf('existing content')).toBeLessThan(
172
+ result.indexOf('tlc:build')
173
+ );
174
+ // Has separator between existing and new
175
+ expect(result).toMatch(/existing content\n\n---\n\n/);
176
+ });
177
+
178
+ it('creates fresh content with header when existing is empty', () => {
179
+ const report = createFieldReport({
180
+ skill: 'tlc:review',
181
+ rating: 3,
182
+ issue: 'Missed edge case',
183
+ suggestion: 'Check boundaries',
184
+ now: nowFn,
185
+ });
186
+
187
+ const result = appendReport('', report);
188
+
189
+ expect(result).toContain('# Field Reports');
190
+ expect(result).toContain(report.trim());
191
+ // No separator before first report
192
+ expect(result).not.toMatch(/---\n\n##/);
193
+ });
194
+ });
195
+ });