tlc-claude-code 2.4.2 → 2.4.4
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 +75 -5
- package/.claude/commands/tlc/discuss.md +174 -123
- package/.claude/commands/tlc/e2e-verify.md +1 -1
- package/.claude/commands/tlc/plan.md +77 -2
- 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/commands/tlc/tlc.md +204 -473
- package/.claude/hooks/tlc-capture-exchange.sh +50 -21
- package/.claude/hooks/tlc-session-init.sh +30 -0
- package/CLAUDE.md +6 -5
- package/bin/init.js +12 -3
- package/package.json +4 -1
- package/scripts/dev-link.sh +29 -0
- package/scripts/test-package.sh +54 -0
- package/scripts/version-sync.js +42 -0
- package/scripts/version-sync.test.js +100 -0
- 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/model-router.js +11 -2
- package/server/lib/model-router.test.js +27 -1
- package/server/lib/orchestration/cli-dispatch.js +200 -0
- package/server/lib/orchestration/cli-dispatch.test.js +242 -0
- package/server/lib/orchestration/codex-orchestrator.js +185 -0
- package/server/lib/orchestration/codex-orchestrator.test.js +221 -0
- package/server/lib/orchestration/dep-linker.js +61 -0
- package/server/lib/orchestration/dep-linker.test.js +174 -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/router-config.js +18 -3
- package/server/lib/router-config.test.js +57 -1
- package/server/lib/routing/index.js +34 -0
- package/server/lib/routing/index.test.js +33 -0
- package/server/lib/routing-command.js +11 -2
- package/server/lib/routing-command.test.js +39 -1
- package/server/lib/routing-preamble.integration.test.js +319 -0
- package/server/lib/routing-preamble.js +34 -11
- package/server/lib/routing-preamble.test.js +11 -0
- package/server/lib/task-router-config.js +35 -14
- package/server/lib/task-router-config.test.js +77 -13
|
@@ -0,0 +1,171 @@
|
|
|
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
|
+
|
|
6
|
+
const SPOOL_PATH = path.join('.tlc', 'memory', '.spool.jsonl');
|
|
7
|
+
|
|
8
|
+
function readSpoolEntries(projectDir) {
|
|
9
|
+
const spoolPath = path.join(projectDir, SPOOL_PATH);
|
|
10
|
+
const content = fs.readFileSync(spoolPath, 'utf8').trim();
|
|
11
|
+
|
|
12
|
+
return content.split('\n').filter(Boolean).map((line) => JSON.parse(line));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe('capture/generic-capture', () => {
|
|
16
|
+
let projectDir;
|
|
17
|
+
let captureFromStdout;
|
|
18
|
+
let captureFromApiResponse;
|
|
19
|
+
|
|
20
|
+
beforeEach(async () => {
|
|
21
|
+
vi.useFakeTimers();
|
|
22
|
+
vi.setSystemTime(new Date('2026-03-28T12:34:56.000Z'));
|
|
23
|
+
projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tlc-generic-capture-'));
|
|
24
|
+
({ captureFromStdout, captureFromApiResponse } = await import('./generic-capture.js'));
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
afterEach(() => {
|
|
28
|
+
vi.useRealTimers();
|
|
29
|
+
vi.resetModules();
|
|
30
|
+
fs.rmSync(projectDir, { recursive: true, force: true });
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('captures stdout into a redacted envelope', () => {
|
|
34
|
+
const result = captureFromStdout({
|
|
35
|
+
stdout: 'token=abc123 password=hunter2 /Users/jurgen/private/app.js',
|
|
36
|
+
provider: 'gemini',
|
|
37
|
+
taskName: 'Implement auth',
|
|
38
|
+
projectDir,
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
const [entry] = readSpoolEntries(projectDir);
|
|
42
|
+
|
|
43
|
+
expect(result).toEqual({ captured: 1, skipped: 0 });
|
|
44
|
+
expect(entry).toMatchObject({
|
|
45
|
+
provider: 'gemini',
|
|
46
|
+
source: 'stdout',
|
|
47
|
+
taskName: 'Implement auth',
|
|
48
|
+
timestamp: '2026-03-28T12:34:56.000Z',
|
|
49
|
+
metadata: {
|
|
50
|
+
assistantMessage: 'token=[TOKEN_REDACTED] password=[REDACTED] ~/private/app.js',
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
expect(entry.text).toBe('token=[TOKEN_REDACTED] password=[REDACTED] ~/private/app.js');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('returns skipped when stdout is empty', () => {
|
|
57
|
+
const result = captureFromStdout({
|
|
58
|
+
stdout: ' ',
|
|
59
|
+
provider: 'gemini',
|
|
60
|
+
taskName: 'Implement auth',
|
|
61
|
+
projectDir,
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
expect(result).toEqual({ captured: 0, skipped: 1 });
|
|
65
|
+
expect(fs.existsSync(path.join(projectDir, SPOOL_PATH))).toBe(false);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('appends to an existing spool file', () => {
|
|
69
|
+
const spoolPath = path.join(projectDir, SPOOL_PATH);
|
|
70
|
+
fs.mkdirSync(path.dirname(spoolPath), { recursive: true });
|
|
71
|
+
fs.writeFileSync(spoolPath, `${JSON.stringify({ existing: true })}\n`);
|
|
72
|
+
|
|
73
|
+
captureFromStdout({
|
|
74
|
+
stdout: 'We decided to use SQLite for local state.',
|
|
75
|
+
provider: 'openrouter',
|
|
76
|
+
taskName: 'State storage',
|
|
77
|
+
projectDir,
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
const lines = fs.readFileSync(spoolPath, 'utf8').trim().split('\n');
|
|
81
|
+
|
|
82
|
+
expect(lines).toHaveLength(2);
|
|
83
|
+
expect(JSON.parse(lines[0])).toEqual({ existing: true });
|
|
84
|
+
expect(JSON.parse(lines[1])).toMatchObject({
|
|
85
|
+
provider: 'openrouter',
|
|
86
|
+
source: 'stdout',
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('captures api response content when present', () => {
|
|
91
|
+
const result = captureFromApiResponse({
|
|
92
|
+
response: {
|
|
93
|
+
content: 'secret=hunter2\nWe decided to use retries.',
|
|
94
|
+
},
|
|
95
|
+
provider: 'gemini',
|
|
96
|
+
taskName: 'Retry policy',
|
|
97
|
+
projectDir,
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
const [entry] = readSpoolEntries(projectDir);
|
|
101
|
+
|
|
102
|
+
expect(result).toEqual({ captured: 1, skipped: 0 });
|
|
103
|
+
expect(entry.source).toBe('api-response');
|
|
104
|
+
expect(entry.text).toContain('[REDACTED]');
|
|
105
|
+
expect(entry.text).toContain('We decided to use retries.');
|
|
106
|
+
expect(entry.text).not.toContain('hunter2');
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('falls back to choices[0].message.content for api responses', () => {
|
|
110
|
+
captureFromApiResponse({
|
|
111
|
+
response: {
|
|
112
|
+
choices: [
|
|
113
|
+
{
|
|
114
|
+
message: {
|
|
115
|
+
content: 'Use Redis for rate limits.',
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
],
|
|
119
|
+
},
|
|
120
|
+
provider: 'openai',
|
|
121
|
+
taskName: 'Rate limits',
|
|
122
|
+
projectDir,
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
const [entry] = readSpoolEntries(projectDir);
|
|
126
|
+
|
|
127
|
+
expect(entry.text).toBe('Use Redis for rate limits.');
|
|
128
|
+
expect(entry.metadata.assistantMessage).toBe('Use Redis for rate limits.');
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('falls back to JSON stringification when api response has no direct text field', () => {
|
|
132
|
+
captureFromApiResponse({
|
|
133
|
+
response: {
|
|
134
|
+
status: 'ok',
|
|
135
|
+
data: {
|
|
136
|
+
message: 'We decided to shard by tenant.',
|
|
137
|
+
},
|
|
138
|
+
},
|
|
139
|
+
provider: 'anthropic-proxy',
|
|
140
|
+
taskName: 'Sharding',
|
|
141
|
+
projectDir,
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
const [entry] = readSpoolEntries(projectDir);
|
|
145
|
+
|
|
146
|
+
expect(entry.text).toContain('"status":"ok"');
|
|
147
|
+
expect(entry.text).toContain('"message":"We decided to shard by tenant."');
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('supports injected fs implementations', () => {
|
|
151
|
+
const injectedFs = {
|
|
152
|
+
...fs,
|
|
153
|
+
appendFileSync: vi.fn(fs.appendFileSync),
|
|
154
|
+
existsSync: vi.fn(fs.existsSync),
|
|
155
|
+
mkdirSync: vi.fn(fs.mkdirSync),
|
|
156
|
+
readFileSync: vi.fn(fs.readFileSync),
|
|
157
|
+
writeFileSync: vi.fn(fs.writeFileSync),
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
const result = captureFromApiResponse({
|
|
161
|
+
response: { content: 'Use a queue for webhooks.' },
|
|
162
|
+
provider: 'gemini',
|
|
163
|
+
taskName: 'Webhook delivery',
|
|
164
|
+
projectDir,
|
|
165
|
+
fs: injectedFs,
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
expect(result).toEqual({ captured: 1, skipped: 0 });
|
|
169
|
+
expect(injectedFs.appendFileSync).toHaveBeenCalledTimes(1);
|
|
170
|
+
});
|
|
171
|
+
});
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
const path = require('path');
|
|
2
|
+
|
|
3
|
+
const { captureFromStdout, captureFromApiResponse } = require('./generic-capture');
|
|
4
|
+
|
|
5
|
+
const zeroCaptureCounts = new Map();
|
|
6
|
+
let warningCount = 0;
|
|
7
|
+
const MAX_WARNINGS_PER_SESSION = 3;
|
|
8
|
+
const WARNING_LOG_PATH = path.join('.tlc', 'memory', '.capture-warnings.log');
|
|
9
|
+
|
|
10
|
+
function warningsEnabled() {
|
|
11
|
+
return process.env.TLC_CAPTURE_WARNINGS !== 'false';
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function maybeWarn(projectDir, fs, message) {
|
|
15
|
+
if (!warningsEnabled() || warningCount >= MAX_WARNINGS_PER_SESSION) {
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
warningCount += 1;
|
|
20
|
+
console.warn(message);
|
|
21
|
+
|
|
22
|
+
if (!projectDir) {
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
try {
|
|
27
|
+
const logPath = path.join(projectDir, WARNING_LOG_PATH);
|
|
28
|
+
fs.mkdirSync(path.dirname(logPath), { recursive: true });
|
|
29
|
+
fs.appendFileSync(logPath, `${new Date().toISOString()} ${message}\n`, 'utf8');
|
|
30
|
+
} catch (_) {
|
|
31
|
+
// Warning emission must not break capture routing.
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function maybeEmitWarning({ provider, output, taskName, projectDir, result, fs }) {
|
|
36
|
+
if (result && result.captured > 0) {
|
|
37
|
+
zeroCaptureCounts.set(provider, 0);
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const nextCount = (zeroCaptureCounts.get(provider) || 0) + 1;
|
|
42
|
+
zeroCaptureCounts.set(provider, nextCount);
|
|
43
|
+
|
|
44
|
+
if (typeof output !== 'string' || output.length <= 500) {
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (nextCount >= 3) {
|
|
49
|
+
maybeWarn(
|
|
50
|
+
projectDir,
|
|
51
|
+
fs,
|
|
52
|
+
`[TLC WARNING] ${provider} has produced 0 memory captures for ${nextCount} consecutive tasks. Check capture configuration.`,
|
|
53
|
+
);
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
maybeWarn(
|
|
58
|
+
projectDir,
|
|
59
|
+
fs,
|
|
60
|
+
`[TLC WARNING] No decisions captured from ${provider} for task "${taskName}". Memory capture may not be working for this provider.`,
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function captureFromProvider({
|
|
65
|
+
provider,
|
|
66
|
+
output,
|
|
67
|
+
taskName,
|
|
68
|
+
threadId,
|
|
69
|
+
projectDir,
|
|
70
|
+
hookInput,
|
|
71
|
+
lastMessageFile,
|
|
72
|
+
jsonStream,
|
|
73
|
+
fs = require('fs'),
|
|
74
|
+
}) {
|
|
75
|
+
let result;
|
|
76
|
+
|
|
77
|
+
if (provider === 'claude') {
|
|
78
|
+
const { captureClaudeExchange } = require('./claude-capture');
|
|
79
|
+
result = captureClaudeExchange({ hookInput, projectDir, fs });
|
|
80
|
+
} else if (provider === 'codex') {
|
|
81
|
+
const { captureCodexOutput } = require('./codex-capture');
|
|
82
|
+
result = captureCodexOutput({
|
|
83
|
+
lastMessageFile,
|
|
84
|
+
jsonStream,
|
|
85
|
+
stdout: output,
|
|
86
|
+
taskName,
|
|
87
|
+
threadId,
|
|
88
|
+
projectDir,
|
|
89
|
+
fs,
|
|
90
|
+
});
|
|
91
|
+
} else {
|
|
92
|
+
result = captureFromStdout({
|
|
93
|
+
stdout: output,
|
|
94
|
+
provider,
|
|
95
|
+
taskName,
|
|
96
|
+
projectDir,
|
|
97
|
+
fs,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
maybeEmitWarning({
|
|
102
|
+
provider,
|
|
103
|
+
output,
|
|
104
|
+
taskName,
|
|
105
|
+
projectDir,
|
|
106
|
+
result,
|
|
107
|
+
fs,
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
return result;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
module.exports = {
|
|
114
|
+
captureFromProvider,
|
|
115
|
+
captureFromStdout,
|
|
116
|
+
captureFromApiResponse,
|
|
117
|
+
};
|
|
@@ -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
|
+
};
|