tlc-claude-code 2.4.3 → 2.4.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/commands/tlc/build.md +33 -13
- package/.claude/commands/tlc/recall.md +59 -87
- package/.claude/commands/tlc/remember.md +76 -71
- package/.claude/commands/tlc/review.md +76 -21
- package/.claude/hooks/tlc-capture-exchange.sh +50 -21
- package/.claude/hooks/tlc-session-init.sh +30 -0
- package/bin/init.js +12 -3
- package/package.json +1 -1
- package/server/lib/capture/classifier.js +71 -0
- package/server/lib/capture/classifier.test.js +71 -0
- package/server/lib/capture/claude-capture.js +140 -0
- package/server/lib/capture/claude-capture.test.js +152 -0
- package/server/lib/capture/codex-capture.js +79 -0
- package/server/lib/capture/codex-capture.test.js +161 -0
- package/server/lib/capture/codex-event-parser.js +76 -0
- package/server/lib/capture/codex-event-parser.test.js +83 -0
- package/server/lib/capture/ensure-ready.js +56 -0
- package/server/lib/capture/ensure-ready.test.js +135 -0
- package/server/lib/capture/envelope.js +77 -0
- package/server/lib/capture/envelope.test.js +169 -0
- package/server/lib/capture/extractor.js +51 -0
- package/server/lib/capture/extractor.test.js +92 -0
- package/server/lib/capture/generic-capture.js +96 -0
- package/server/lib/capture/generic-capture.test.js +171 -0
- package/server/lib/capture/index.js +117 -0
- package/server/lib/capture/index.test.js +263 -0
- package/server/lib/capture/redactor.js +68 -0
- package/server/lib/capture/redactor.test.js +93 -0
- package/server/lib/capture/spool-processor.js +155 -0
- package/server/lib/capture/spool-processor.test.js +278 -0
- package/server/lib/health-check.js +255 -0
- package/server/lib/health-check.test.js +243 -0
- package/server/lib/orchestration/cli-dispatch.js +200 -0
- package/server/lib/orchestration/cli-dispatch.test.js +242 -0
- package/server/lib/orchestration/prompt-builder.js +118 -0
- package/server/lib/orchestration/prompt-builder.test.js +200 -0
- package/server/lib/orchestration/standalone-compat.js +39 -0
- package/server/lib/orchestration/standalone-compat.test.js +144 -0
- package/server/lib/orchestration/worktree-manager.js +43 -0
- package/server/lib/orchestration/worktree-manager.test.js +50 -0
- package/server/lib/task-router-config.js +22 -5
- package/server/lib/task-router-config.test.js +46 -13
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
import { createRequire } from 'module';
|
|
6
|
+
|
|
7
|
+
const SPOOL_PATH = path.join('.tlc', 'memory', '.spool.jsonl');
|
|
8
|
+
const WARNING_LOG = path.join('.tlc', 'memory', '.capture-warnings.log');
|
|
9
|
+
const require = createRequire(import.meta.url);
|
|
10
|
+
|
|
11
|
+
function longOutput() {
|
|
12
|
+
return 'Decision '.repeat(80);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function readSpoolEntries(projectDir) {
|
|
16
|
+
const spoolPath = path.join(projectDir, SPOOL_PATH);
|
|
17
|
+
const content = fs.readFileSync(spoolPath, 'utf8').trim();
|
|
18
|
+
|
|
19
|
+
return content.split('\n').filter(Boolean).map((line) => JSON.parse(line));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function createFailingFs() {
|
|
23
|
+
return {
|
|
24
|
+
...fs,
|
|
25
|
+
appendFileSync: vi.fn(() => {
|
|
26
|
+
throw new Error('append failed');
|
|
27
|
+
}),
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe('capture/index', () => {
|
|
32
|
+
let projectDir;
|
|
33
|
+
let captureFromProvider;
|
|
34
|
+
let captureFromStdout;
|
|
35
|
+
let captureFromApiResponse;
|
|
36
|
+
|
|
37
|
+
beforeEach(async () => {
|
|
38
|
+
vi.useFakeTimers();
|
|
39
|
+
vi.setSystemTime(new Date('2026-03-28T12:34:56.000Z'));
|
|
40
|
+
projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tlc-capture-index-'));
|
|
41
|
+
delete process.env.TLC_CAPTURE_WARNINGS;
|
|
42
|
+
({ captureFromProvider, captureFromStdout, captureFromApiResponse } = await import('./index.js'));
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
afterEach(() => {
|
|
46
|
+
vi.useRealTimers();
|
|
47
|
+
vi.restoreAllMocks();
|
|
48
|
+
vi.resetModules();
|
|
49
|
+
delete process.env.TLC_CAPTURE_WARNINGS;
|
|
50
|
+
fs.rmSync(projectDir, { recursive: true, force: true });
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('routes claude to claude-capture', () => {
|
|
54
|
+
captureFromProvider({
|
|
55
|
+
provider: 'claude',
|
|
56
|
+
output: 'stdout fallback',
|
|
57
|
+
taskName: 'Task',
|
|
58
|
+
hookInput: JSON.stringify({
|
|
59
|
+
session_id: 'sess-1',
|
|
60
|
+
assistant_message: 'We decided to use Postgres.',
|
|
61
|
+
}),
|
|
62
|
+
projectDir,
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
const [entry] = readSpoolEntries(projectDir);
|
|
66
|
+
|
|
67
|
+
expect(entry).toMatchObject({
|
|
68
|
+
provider: 'claude',
|
|
69
|
+
source: 'stop-hook',
|
|
70
|
+
threadId: 'sess-1',
|
|
71
|
+
text: 'We decided to use Postgres.',
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('routes codex to codex-capture with the expected source resolution', () => {
|
|
76
|
+
captureFromProvider({
|
|
77
|
+
provider: 'codex',
|
|
78
|
+
output: 'stdout fallback',
|
|
79
|
+
taskName: 'Task',
|
|
80
|
+
threadId: null,
|
|
81
|
+
jsonStream: [
|
|
82
|
+
'{"type":"thread.started","thread_id":"thread-from-json"}',
|
|
83
|
+
JSON.stringify({
|
|
84
|
+
type: 'agent_message',
|
|
85
|
+
message: {
|
|
86
|
+
items: [
|
|
87
|
+
{ type: 'text', text: 'First agent message' },
|
|
88
|
+
{ type: 'text', text: 'Second agent message' },
|
|
89
|
+
],
|
|
90
|
+
},
|
|
91
|
+
}),
|
|
92
|
+
].join('\n'),
|
|
93
|
+
projectDir,
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
const [entry] = readSpoolEntries(projectDir);
|
|
97
|
+
|
|
98
|
+
expect(entry).toMatchObject({
|
|
99
|
+
provider: 'codex',
|
|
100
|
+
source: 'json-stream',
|
|
101
|
+
threadId: 'thread-from-json',
|
|
102
|
+
text: 'First agent message\n\nSecond agent message',
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('routes unknown providers to generic stdout capture', () => {
|
|
107
|
+
captureFromProvider({
|
|
108
|
+
provider: 'gemini',
|
|
109
|
+
output: 'Use Redis for caching.',
|
|
110
|
+
taskName: 'Task',
|
|
111
|
+
projectDir,
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
const [entry] = readSpoolEntries(projectDir);
|
|
115
|
+
|
|
116
|
+
expect(entry).toMatchObject({
|
|
117
|
+
provider: 'gemini',
|
|
118
|
+
source: 'stdout',
|
|
119
|
+
taskName: 'Task',
|
|
120
|
+
text: 'Use Redis for caching.',
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('re-exports generic capture functions', async () => {
|
|
125
|
+
const index = require('./index.js');
|
|
126
|
+
const generic = require('./generic-capture.js');
|
|
127
|
+
|
|
128
|
+
expect(index.captureFromStdout).toBe(generic.captureFromStdout);
|
|
129
|
+
expect(index.captureFromApiResponse).toBe(generic.captureFromApiResponse);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('does not warn when captures succeed', () => {
|
|
133
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
134
|
+
|
|
135
|
+
captureFromProvider({
|
|
136
|
+
provider: 'gemini',
|
|
137
|
+
output: longOutput(),
|
|
138
|
+
taskName: 'Task',
|
|
139
|
+
projectDir,
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
expect(warnSpy).not.toHaveBeenCalled();
|
|
143
|
+
expect(fs.existsSync(path.join(projectDir, WARNING_LOG))).toBe(false);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('does not warn when output is too short', () => {
|
|
147
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
148
|
+
|
|
149
|
+
captureFromProvider({
|
|
150
|
+
provider: 'claude',
|
|
151
|
+
output: 'short output',
|
|
152
|
+
taskName: 'Task',
|
|
153
|
+
hookInput: '{"session_id"',
|
|
154
|
+
projectDir,
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
expect(warnSpy).not.toHaveBeenCalled();
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('warns and writes to the warning log when a long output captures nothing', () => {
|
|
161
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
162
|
+
|
|
163
|
+
captureFromProvider({
|
|
164
|
+
provider: 'claude',
|
|
165
|
+
output: longOutput(),
|
|
166
|
+
taskName: 'Investigate retries',
|
|
167
|
+
hookInput: '{"session_id"',
|
|
168
|
+
projectDir,
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
const logPath = path.join(projectDir, WARNING_LOG);
|
|
172
|
+
|
|
173
|
+
expect(warnSpy).toHaveBeenCalledTimes(1);
|
|
174
|
+
expect(warnSpy.mock.calls[0][0]).toContain('No decisions captured from claude');
|
|
175
|
+
expect(warnSpy.mock.calls[0][0]).toContain('Investigate retries');
|
|
176
|
+
expect(fs.readFileSync(logPath, 'utf8')).toContain('2026-03-28T12:34:56.000Z');
|
|
177
|
+
expect(fs.readFileSync(logPath, 'utf8')).toContain('No decisions captured from claude');
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('escalates on the third consecutive zero capture for the same provider', () => {
|
|
181
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
182
|
+
|
|
183
|
+
captureFromProvider({ provider: 'claude', output: longOutput(), taskName: 'Task 1', hookInput: '{"bad"', projectDir });
|
|
184
|
+
captureFromProvider({ provider: 'claude', output: longOutput(), taskName: 'Task 2', hookInput: '{"bad"', projectDir });
|
|
185
|
+
captureFromProvider({ provider: 'claude', output: longOutput(), taskName: 'Task 3', hookInput: '{"bad"', projectDir });
|
|
186
|
+
|
|
187
|
+
expect(warnSpy).toHaveBeenCalledTimes(3);
|
|
188
|
+
expect(warnSpy.mock.calls[2][0]).toContain('claude has produced 0 memory captures for 3 consecutive tasks');
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('resets the consecutive zero counter after a successful capture', () => {
|
|
192
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
193
|
+
|
|
194
|
+
captureFromProvider({ provider: 'claude', output: longOutput(), taskName: 'Task 1', hookInput: '{"bad"', projectDir });
|
|
195
|
+
captureFromProvider({ provider: 'claude', output: longOutput(), taskName: 'Task 2', hookInput: '{"bad"', projectDir });
|
|
196
|
+
captureFromProvider({
|
|
197
|
+
provider: 'claude',
|
|
198
|
+
output: longOutput(),
|
|
199
|
+
taskName: 'Task 3',
|
|
200
|
+
hookInput: JSON.stringify({
|
|
201
|
+
session_id: 'sess-2',
|
|
202
|
+
assistant_message: 'We decided to use Redis.',
|
|
203
|
+
}),
|
|
204
|
+
projectDir,
|
|
205
|
+
});
|
|
206
|
+
captureFromProvider({ provider: 'claude', output: longOutput(), taskName: 'Task 4', hookInput: '{"bad"', projectDir });
|
|
207
|
+
|
|
208
|
+
expect(warnSpy).toHaveBeenCalledTimes(3);
|
|
209
|
+
expect(warnSpy.mock.calls[2][0]).toContain('No decisions captured from claude');
|
|
210
|
+
expect(warnSpy.mock.calls[2][0]).not.toContain('consecutive tasks');
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it('tracks consecutive zero captures per provider independently', () => {
|
|
214
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
215
|
+
const failingFs = createFailingFs();
|
|
216
|
+
|
|
217
|
+
captureFromProvider({ provider: 'claude', output: longOutput(), taskName: 'Task 1', hookInput: '{"bad"', projectDir });
|
|
218
|
+
captureFromProvider({
|
|
219
|
+
provider: 'codex',
|
|
220
|
+
output: longOutput(),
|
|
221
|
+
taskName: 'Task 2',
|
|
222
|
+
threadId: 'thread-1',
|
|
223
|
+
projectDir,
|
|
224
|
+
fs: failingFs,
|
|
225
|
+
});
|
|
226
|
+
captureFromProvider({ provider: 'claude', output: longOutput(), taskName: 'Task 3', hookInput: '{"bad"', projectDir });
|
|
227
|
+
|
|
228
|
+
expect(warnSpy).toHaveBeenCalledTimes(3);
|
|
229
|
+
expect(warnSpy.mock.calls[1][0]).toContain('No decisions captured from codex');
|
|
230
|
+
expect(warnSpy.mock.calls[2][0]).toContain('No decisions captured from claude');
|
|
231
|
+
expect(warnSpy.mock.calls[2][0]).not.toContain('consecutive tasks');
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it('caps warnings at three per session', () => {
|
|
235
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
236
|
+
|
|
237
|
+
captureFromProvider({ provider: 'claude', output: longOutput(), taskName: 'Task 1', hookInput: '{"bad"', projectDir });
|
|
238
|
+
captureFromProvider({ provider: 'claude', output: longOutput(), taskName: 'Task 2', hookInput: '{"bad"', projectDir });
|
|
239
|
+
captureFromProvider({ provider: 'claude', output: longOutput(), taskName: 'Task 3', hookInput: '{"bad"', projectDir });
|
|
240
|
+
captureFromProvider({ provider: 'claude', output: longOutput(), taskName: 'Task 4', hookInput: '{"bad"', projectDir });
|
|
241
|
+
captureFromProvider({ provider: 'claude', output: longOutput(), taskName: 'Task 5', hookInput: '{"bad"', projectDir });
|
|
242
|
+
|
|
243
|
+
expect(warnSpy).toHaveBeenCalledTimes(3);
|
|
244
|
+
const logLines = fs.readFileSync(path.join(projectDir, WARNING_LOG), 'utf8').trim().split('\n');
|
|
245
|
+
expect(logLines).toHaveLength(3);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it('disables warnings entirely when TLC_CAPTURE_WARNINGS is false', () => {
|
|
249
|
+
process.env.TLC_CAPTURE_WARNINGS = 'false';
|
|
250
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
251
|
+
|
|
252
|
+
captureFromProvider({
|
|
253
|
+
provider: 'claude',
|
|
254
|
+
output: longOutput(),
|
|
255
|
+
taskName: 'Investigate retries',
|
|
256
|
+
hookInput: '{"session_id"',
|
|
257
|
+
projectDir,
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
expect(warnSpy).not.toHaveBeenCalled();
|
|
261
|
+
expect(fs.existsSync(path.join(projectDir, WARNING_LOG))).toBe(false);
|
|
262
|
+
});
|
|
263
|
+
});
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
function redactKeyValue(input, key, replacement) {
|
|
2
|
+
const quotedPattern = new RegExp(`(["']?${key}["']?)(\\s*[:=]\\s*)(["'])(.*?)\\3`, 'gi');
|
|
3
|
+
const barePattern = new RegExp(`(["']?${key}["']?)(\\s*[:=]\\s*)([^\\s"',}]+)`, 'gi');
|
|
4
|
+
|
|
5
|
+
return input
|
|
6
|
+
.replace(quotedPattern, (_, matchedKey, separator, quote) => `${matchedKey}${separator}${quote}${replacement}${quote}`)
|
|
7
|
+
.replace(barePattern, (_, matchedKey, separator) => `${matchedKey}${separator}${replacement}`);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function redactPaths(input) {
|
|
11
|
+
return input
|
|
12
|
+
.replace(/\/Users\/[^/\s]+\/([^\s]+)/g, '~/$1')
|
|
13
|
+
.replace(/\/home\/[^/\s]+\/([^\s]+)/g, '~/$1');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function collapseStackTrace(input) {
|
|
17
|
+
const lines = input.split('\n');
|
|
18
|
+
|
|
19
|
+
if (lines.length < 4) {
|
|
20
|
+
return input;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const stackLikeLineCount = lines.slice(1).filter((line) => /^\s+at\b/.test(line) || /^(Caused by|Error:)/.test(line)).length;
|
|
24
|
+
|
|
25
|
+
if (stackLikeLineCount < 2) {
|
|
26
|
+
return input;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return `${lines[0]}\n... ${lines.length - 2} more lines\n${lines[lines.length - 1]}`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function applyExtraPatterns(input, extraPatterns) {
|
|
33
|
+
let output = input;
|
|
34
|
+
|
|
35
|
+
for (const entry of extraPatterns) {
|
|
36
|
+
if (!entry || !(entry.pattern instanceof RegExp) || typeof entry.replacement !== 'string') {
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
output = output.replace(entry.pattern, entry.replacement);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return output;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function redact(text, { extraPatterns = [] } = {}) {
|
|
47
|
+
let output = String(text);
|
|
48
|
+
|
|
49
|
+
output = output.replace(/\bsk-[A-Za-z0-9_-]+\b/g, '[API_KEY_REDACTED]');
|
|
50
|
+
output = output.replace(/\bgh[pus]_[A-Za-z0-9_]+\b/g, '[API_KEY_REDACTED]');
|
|
51
|
+
output = output.replace(/\bBearer\s+[A-Za-z0-9._-]+\b/gi, 'Bearer [TOKEN_REDACTED]');
|
|
52
|
+
|
|
53
|
+
output = redactKeyValue(output, 'token', '[TOKEN_REDACTED]');
|
|
54
|
+
output = redactKeyValue(output, 'password', '[REDACTED]');
|
|
55
|
+
output = redactKeyValue(output, 'secret', '[REDACTED]');
|
|
56
|
+
|
|
57
|
+
output = redactPaths(output);
|
|
58
|
+
output = output.replace(/([=:])([A-Za-z0-9+/=]{101,})(?=[^A-Za-z0-9+/=]|$)/g, '$1[BASE64_REDACTED]');
|
|
59
|
+
output = output.replace(/(^|[\s([{,])([A-Za-z0-9+/=]{101,})(?=$|[\s)\]},])/g, (_, prefix) => `${prefix}[BASE64_REDACTED]`);
|
|
60
|
+
output = collapseStackTrace(output);
|
|
61
|
+
output = applyExtraPatterns(output, extraPatterns);
|
|
62
|
+
|
|
63
|
+
return output;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
module.exports = {
|
|
67
|
+
redact,
|
|
68
|
+
};
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
|
|
3
|
+
describe('capture/redactor', () => {
|
|
4
|
+
it('redacts OpenAI style API keys', async () => {
|
|
5
|
+
const { redact } = await import('./redactor.js');
|
|
6
|
+
|
|
7
|
+
expect(redact('token sk-abcdefghijklmnopqrstuvwxyz123456')).toContain('[API_KEY_REDACTED]');
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it('redacts GitHub tokens with ghp_ and ghu_ prefixes', async () => {
|
|
11
|
+
const { redact } = await import('./redactor.js');
|
|
12
|
+
|
|
13
|
+
const output = redact('ghp_abcdefghijklmnopqrstuvwxyz123456 and ghu_abcdefghijklmnopqrstuvwxyz123456');
|
|
14
|
+
|
|
15
|
+
expect(output).not.toContain('ghp_abcdefghijklmnopqrstuvwxyz123456');
|
|
16
|
+
expect(output).not.toContain('ghu_abcdefghijklmnopqrstuvwxyz123456');
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('redacts bearer tokens', async () => {
|
|
20
|
+
const { redact } = await import('./redactor.js');
|
|
21
|
+
|
|
22
|
+
expect(redact('Authorization: Bearer abc.def.ghi')).toBe('Authorization: Bearer [TOKEN_REDACTED]');
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('redacts token fields inside quoted JSON-like text', async () => {
|
|
26
|
+
const { redact } = await import('./redactor.js');
|
|
27
|
+
|
|
28
|
+
expect(redact('{"token":"abc123secret"}')).toBe('{"token":"[TOKEN_REDACTED]"}');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('redacts password and secret assignments', async () => {
|
|
32
|
+
const { redact } = await import('./redactor.js');
|
|
33
|
+
|
|
34
|
+
const output = redact('password=hunter2 secret=abc PASSWORD="quoted-secret"');
|
|
35
|
+
|
|
36
|
+
expect(output).toContain('password=[REDACTED]');
|
|
37
|
+
expect(output).toContain('secret=[REDACTED]');
|
|
38
|
+
expect(output).toContain('PASSWORD="[REDACTED]"');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('rewrites absolute user paths under /Users and /home', async () => {
|
|
42
|
+
const { redact } = await import('./redactor.js');
|
|
43
|
+
|
|
44
|
+
const output = redact('See /Users/jurgen/project/file.js and /home/alice/work/app.js');
|
|
45
|
+
|
|
46
|
+
expect(output).toBe('See ~/project/file.js and ~/work/app.js');
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('collapses stack traces to first and last line with omitted count', async () => {
|
|
50
|
+
const { redact } = await import('./redactor.js');
|
|
51
|
+
const input = [
|
|
52
|
+
'Error: boom',
|
|
53
|
+
' at a (/tmp/a.js:1:1)',
|
|
54
|
+
' at b (/tmp/b.js:2:2)',
|
|
55
|
+
' at c (/tmp/c.js:3:3)',
|
|
56
|
+
'Caused by: root issue',
|
|
57
|
+
].join('\n');
|
|
58
|
+
|
|
59
|
+
expect(redact(input)).toBe('Error: boom\n... 3 more lines\nCaused by: root issue');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('does not collapse short multi-line text that is not a stack trace', async () => {
|
|
63
|
+
const { redact } = await import('./redactor.js');
|
|
64
|
+
const input = 'line one\nline two';
|
|
65
|
+
|
|
66
|
+
expect(redact(input)).toBe('line one\nline two');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('redacts long base64 blobs', async () => {
|
|
70
|
+
const { redact } = await import('./redactor.js');
|
|
71
|
+
const blob = 'QUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVo='.repeat(4);
|
|
72
|
+
|
|
73
|
+
expect(redact(`payload=${blob}`)).toBe('payload=[BASE64_REDACTED]');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('applies extra custom patterns after built-in redaction', async () => {
|
|
77
|
+
const { redact } = await import('./redactor.js');
|
|
78
|
+
|
|
79
|
+
const output = redact('Order ID: 12345', {
|
|
80
|
+
extraPatterns: [
|
|
81
|
+
{ pattern: /12345/g, replacement: '[ORDER_ID]' },
|
|
82
|
+
],
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
expect(output).toBe('Order ID: [ORDER_ID]');
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('returns the original string when there is nothing to redact', async () => {
|
|
89
|
+
const { redact } = await import('./redactor.js');
|
|
90
|
+
|
|
91
|
+
expect(redact('plain text only')).toBe('plain text only');
|
|
92
|
+
});
|
|
93
|
+
});
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
const path = require('path');
|
|
2
|
+
|
|
3
|
+
const { validateEnvelope } = require('./envelope');
|
|
4
|
+
const { redact } = require('./redactor');
|
|
5
|
+
const { extractDecisions } = require('./extractor');
|
|
6
|
+
const { classify } = require('./classifier');
|
|
7
|
+
const { ensureMemoryReady } = require('./ensure-ready');
|
|
8
|
+
|
|
9
|
+
const SPOOL_PATH = path.join('.tlc', 'memory', '.spool.jsonl');
|
|
10
|
+
|
|
11
|
+
function escapeYamlString(value) {
|
|
12
|
+
return String(value ?? '').replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function slugify(statement) {
|
|
16
|
+
const words = String(statement)
|
|
17
|
+
.toLowerCase()
|
|
18
|
+
.replace(/['"]/g, '')
|
|
19
|
+
.match(/[a-z0-9]+/g) || [];
|
|
20
|
+
|
|
21
|
+
const slug = words.slice(0, 5).join('-').slice(0, 40).replace(/-+$/g, '');
|
|
22
|
+
|
|
23
|
+
return slug || 'entry';
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function dateFromTimestamp(timestamp) {
|
|
27
|
+
const value = typeof timestamp === 'string' ? timestamp.slice(0, 10) : '';
|
|
28
|
+
return /^\d{4}-\d{2}-\d{2}$/.test(value) ? value : 'unknown-date';
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function buildFrontmatter(envelope, classification, confidence, statement) {
|
|
32
|
+
return [
|
|
33
|
+
'---',
|
|
34
|
+
`provider: ${envelope.provider}`,
|
|
35
|
+
`source: ${envelope.source || 'unknown'}`,
|
|
36
|
+
`timestamp: ${envelope.timestamp}`,
|
|
37
|
+
`taskName: "${escapeYamlString(envelope.taskName || '')}"`,
|
|
38
|
+
`confidence: ${Number(confidence.toFixed(4))}`,
|
|
39
|
+
`type: ${classification.type}`,
|
|
40
|
+
`scope: ${classification.scope}`,
|
|
41
|
+
'---',
|
|
42
|
+
'',
|
|
43
|
+
statement,
|
|
44
|
+
'',
|
|
45
|
+
].join('\n');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function resolveTargetDir(classification) {
|
|
49
|
+
if (classification.scope === 'team' && classification.type === 'decision') {
|
|
50
|
+
return path.join('.tlc', 'memory', 'team', 'decisions');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (classification.scope === 'team' && classification.type === 'gotcha') {
|
|
54
|
+
return path.join('.tlc', 'memory', 'team', 'gotchas');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return path.join('.tlc', 'memory', '.local', 'sessions');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function nextAvailableFilePath(projectDir, relativeDir, baseName, fs) {
|
|
61
|
+
const directory = path.join(projectDir, relativeDir);
|
|
62
|
+
let candidate = `${baseName}.md`;
|
|
63
|
+
let counter = 2;
|
|
64
|
+
|
|
65
|
+
while (fs.existsSync(path.join(directory, candidate))) {
|
|
66
|
+
candidate = `${baseName}-${counter}.md`;
|
|
67
|
+
counter += 1;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return path.join(directory, candidate);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function writeStatements(projectDir, envelope, statements, fs) {
|
|
74
|
+
for (const entry of statements) {
|
|
75
|
+
const classification = classify(entry.statement);
|
|
76
|
+
|
|
77
|
+
if (!classification || classification.type === 'session') {
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const relativeDir = resolveTargetDir(classification);
|
|
82
|
+
const baseName = `${dateFromTimestamp(envelope.timestamp)}-${slugify(entry.statement)}`;
|
|
83
|
+
const targetPath = nextAvailableFilePath(projectDir, relativeDir, baseName, fs);
|
|
84
|
+
const content = buildFrontmatter(envelope, classification, entry.confidence, entry.statement);
|
|
85
|
+
|
|
86
|
+
fs.writeFileSync(targetPath, content);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function processSpool(projectDir, { fs = require('fs') } = {}) {
|
|
91
|
+
const spoolPath = path.join(projectDir, SPOOL_PATH);
|
|
92
|
+
|
|
93
|
+
if (!fs.existsSync(spoolPath)) {
|
|
94
|
+
return { processed: 0, skipped: 0, warnings: [] };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const input = fs.readFileSync(spoolPath, 'utf8');
|
|
98
|
+
const lines = input === '' ? [] : input.split('\n');
|
|
99
|
+
const warnings = [];
|
|
100
|
+
const remainingLines = [];
|
|
101
|
+
let processed = 0;
|
|
102
|
+
let skipped = 0;
|
|
103
|
+
let ensured = false;
|
|
104
|
+
|
|
105
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
106
|
+
const line = lines[index];
|
|
107
|
+
|
|
108
|
+
if (!line.trim()) {
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
let envelope;
|
|
113
|
+
|
|
114
|
+
try {
|
|
115
|
+
envelope = JSON.parse(line);
|
|
116
|
+
} catch (error) {
|
|
117
|
+
skipped += 1;
|
|
118
|
+
remainingLines.push(line);
|
|
119
|
+
warnings.push(`Malformed JSON on line ${index + 1}: ${error.message}`);
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const validation = validateEnvelope(envelope);
|
|
124
|
+
|
|
125
|
+
if (!validation.valid) {
|
|
126
|
+
skipped += 1;
|
|
127
|
+
remainingLines.push(line);
|
|
128
|
+
warnings.push(`Invalid envelope on line ${index + 1}: ${validation.errors.join(', ')}`);
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const redactedText = redact(envelope.text);
|
|
133
|
+
const statements = extractDecisions(redactedText);
|
|
134
|
+
|
|
135
|
+
if (!ensured) {
|
|
136
|
+
ensureMemoryReady(projectDir, { fs });
|
|
137
|
+
ensured = true;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
writeStatements(projectDir, envelope, statements, fs);
|
|
141
|
+
processed += 1;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
fs.writeFileSync(spoolPath, remainingLines.join('\n'));
|
|
145
|
+
|
|
146
|
+
return {
|
|
147
|
+
processed,
|
|
148
|
+
skipped,
|
|
149
|
+
warnings,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
module.exports = {
|
|
154
|
+
processSpool,
|
|
155
|
+
};
|