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,56 @@
|
|
|
1
|
+
const path = require('path');
|
|
2
|
+
|
|
3
|
+
const REQUIRED_DIRECTORIES = [
|
|
4
|
+
path.join('.tlc', 'memory', 'team', 'decisions'),
|
|
5
|
+
path.join('.tlc', 'memory', 'team', 'gotchas'),
|
|
6
|
+
path.join('.tlc', 'memory', '.local', 'sessions'),
|
|
7
|
+
];
|
|
8
|
+
|
|
9
|
+
const REQUIRED_GITKEEPS = [
|
|
10
|
+
path.join('.tlc', 'memory', 'team', 'decisions', '.gitkeep'),
|
|
11
|
+
path.join('.tlc', 'memory', 'team', 'gotchas', '.gitkeep'),
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
function ensureDirectory(projectDir, relativePath, fs, created, existing) {
|
|
15
|
+
const fullPath = path.join(projectDir, relativePath);
|
|
16
|
+
|
|
17
|
+
if (fs.existsSync(fullPath)) {
|
|
18
|
+
existing.push(relativePath);
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
fs.mkdirSync(fullPath, { recursive: true });
|
|
23
|
+
created.push(relativePath);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function ensureFile(projectDir, relativePath, fs, created, existing) {
|
|
27
|
+
const fullPath = path.join(projectDir, relativePath);
|
|
28
|
+
|
|
29
|
+
if (fs.existsSync(fullPath)) {
|
|
30
|
+
existing.push(relativePath);
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
|
|
35
|
+
fs.writeFileSync(fullPath, '');
|
|
36
|
+
created.push(relativePath);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function ensureMemoryReady(projectDir, { fs = require('fs') } = {}) {
|
|
40
|
+
const created = [];
|
|
41
|
+
const existing = [];
|
|
42
|
+
|
|
43
|
+
for (const relativePath of REQUIRED_DIRECTORIES) {
|
|
44
|
+
ensureDirectory(projectDir, relativePath, fs, created, existing);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
for (const relativePath of REQUIRED_GITKEEPS) {
|
|
48
|
+
ensureFile(projectDir, relativePath, fs, created, existing);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return { created, existing };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
module.exports = {
|
|
55
|
+
ensureMemoryReady,
|
|
56
|
+
};
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
|
|
6
|
+
const TARGETS = [
|
|
7
|
+
path.join('.tlc', 'memory', 'team', 'decisions'),
|
|
8
|
+
path.join('.tlc', 'memory', 'team', 'gotchas'),
|
|
9
|
+
path.join('.tlc', 'memory', '.local', 'sessions'),
|
|
10
|
+
];
|
|
11
|
+
|
|
12
|
+
const GITKEEPS = [
|
|
13
|
+
path.join('.tlc', 'memory', 'team', 'decisions', '.gitkeep'),
|
|
14
|
+
path.join('.tlc', 'memory', 'team', 'gotchas', '.gitkeep'),
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
describe('ensureMemoryReady', () => {
|
|
18
|
+
let projectDir;
|
|
19
|
+
let ensureMemoryReady;
|
|
20
|
+
|
|
21
|
+
beforeEach(async () => {
|
|
22
|
+
projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tlc-ensure-ready-test-'));
|
|
23
|
+
({ ensureMemoryReady } = await import('./ensure-ready.js'));
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
afterEach(() => {
|
|
27
|
+
vi.resetModules();
|
|
28
|
+
fs.rmSync(projectDir, { recursive: true, force: true });
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('creates the canonical memory directories and team .gitkeep files', () => {
|
|
32
|
+
const result = ensureMemoryReady(projectDir);
|
|
33
|
+
|
|
34
|
+
expect(result.created.slice().sort()).toEqual([...TARGETS, ...GITKEEPS].sort());
|
|
35
|
+
expect(result.existing).toEqual([]);
|
|
36
|
+
|
|
37
|
+
for (const relativePath of TARGETS) {
|
|
38
|
+
expect(fs.existsSync(path.join(projectDir, relativePath))).toBe(true);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
for (const relativePath of GITKEEPS) {
|
|
42
|
+
expect(fs.existsSync(path.join(projectDir, relativePath))).toBe(true);
|
|
43
|
+
expect(fs.readFileSync(path.join(projectDir, relativePath), 'utf8')).toBe('');
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('is idempotent across repeated calls', () => {
|
|
48
|
+
const first = ensureMemoryReady(projectDir);
|
|
49
|
+
const second = ensureMemoryReady(projectDir);
|
|
50
|
+
|
|
51
|
+
expect(first.created.slice().sort()).toEqual([...TARGETS, ...GITKEEPS].sort());
|
|
52
|
+
expect(second.created).toEqual([]);
|
|
53
|
+
expect(second.existing.slice().sort()).toEqual([...TARGETS, ...GITKEEPS].sort());
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('reports pre-existing directories and .gitkeep files separately from new ones', () => {
|
|
57
|
+
fs.mkdirSync(path.join(projectDir, '.tlc', 'memory', 'team', 'decisions'), { recursive: true });
|
|
58
|
+
fs.writeFileSync(path.join(projectDir, '.tlc', 'memory', 'team', 'decisions', '.gitkeep'), '');
|
|
59
|
+
fs.mkdirSync(path.join(projectDir, '.tlc', 'memory', '.local', 'sessions'), { recursive: true });
|
|
60
|
+
|
|
61
|
+
const result = ensureMemoryReady(projectDir);
|
|
62
|
+
|
|
63
|
+
expect(result.created.slice().sort()).toEqual([
|
|
64
|
+
path.join('.tlc', 'memory', 'team', 'gotchas'),
|
|
65
|
+
path.join('.tlc', 'memory', 'team', 'gotchas', '.gitkeep'),
|
|
66
|
+
].sort());
|
|
67
|
+
expect(result.existing.slice().sort()).toEqual([
|
|
68
|
+
path.join('.tlc', 'memory', 'team', 'decisions'),
|
|
69
|
+
path.join('.tlc', 'memory', 'team', 'decisions', '.gitkeep'),
|
|
70
|
+
path.join('.tlc', 'memory', '.local', 'sessions'),
|
|
71
|
+
].sort());
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('creates missing .gitkeep files when team directories already exist', () => {
|
|
75
|
+
fs.mkdirSync(path.join(projectDir, '.tlc', 'memory', 'team', 'decisions'), { recursive: true });
|
|
76
|
+
fs.mkdirSync(path.join(projectDir, '.tlc', 'memory', 'team', 'gotchas'), { recursive: true });
|
|
77
|
+
fs.mkdirSync(path.join(projectDir, '.tlc', 'memory', '.local', 'sessions'), { recursive: true });
|
|
78
|
+
|
|
79
|
+
const result = ensureMemoryReady(projectDir);
|
|
80
|
+
|
|
81
|
+
expect(result.created.slice().sort()).toEqual(GITKEEPS.slice().sort());
|
|
82
|
+
expect(result.existing.slice().sort()).toEqual(TARGETS.slice().sort());
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('does not overwrite existing .gitkeep content', () => {
|
|
86
|
+
const decisionsGitkeep = path.join(projectDir, '.tlc', 'memory', 'team', 'decisions', '.gitkeep');
|
|
87
|
+
const gotchasGitkeep = path.join(projectDir, '.tlc', 'memory', 'team', 'gotchas', '.gitkeep');
|
|
88
|
+
|
|
89
|
+
fs.mkdirSync(path.dirname(decisionsGitkeep), { recursive: true });
|
|
90
|
+
fs.mkdirSync(path.dirname(gotchasGitkeep), { recursive: true });
|
|
91
|
+
fs.mkdirSync(path.join(projectDir, '.tlc', 'memory', '.local', 'sessions'), { recursive: true });
|
|
92
|
+
fs.writeFileSync(decisionsGitkeep, 'keep me\n');
|
|
93
|
+
fs.writeFileSync(gotchasGitkeep, 'still here\n');
|
|
94
|
+
|
|
95
|
+
const result = ensureMemoryReady(projectDir);
|
|
96
|
+
|
|
97
|
+
expect(result.created).toEqual([]);
|
|
98
|
+
expect(result.existing.slice().sort()).toEqual([...TARGETS, ...GITKEEPS].sort());
|
|
99
|
+
expect(fs.readFileSync(decisionsGitkeep, 'utf8')).toBe('keep me\n');
|
|
100
|
+
expect(fs.readFileSync(gotchasGitkeep, 'utf8')).toBe('still here\n');
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('creates parent directories recursively from an empty project root', () => {
|
|
104
|
+
ensureMemoryReady(projectDir);
|
|
105
|
+
|
|
106
|
+
expect(fs.existsSync(path.join(projectDir, '.tlc'))).toBe(true);
|
|
107
|
+
expect(fs.existsSync(path.join(projectDir, '.tlc', 'memory', 'team'))).toBe(true);
|
|
108
|
+
expect(fs.existsSync(path.join(projectDir, '.tlc', 'memory', '.local'))).toBe(true);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('supports injected fs implementations', () => {
|
|
112
|
+
const injectedFs = {
|
|
113
|
+
...fs,
|
|
114
|
+
mkdirSync: vi.fn(fs.mkdirSync),
|
|
115
|
+
writeFileSync: vi.fn(fs.writeFileSync),
|
|
116
|
+
existsSync: vi.fn(fs.existsSync),
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const result = ensureMemoryReady(projectDir, { fs: injectedFs });
|
|
120
|
+
|
|
121
|
+
expect(result.created.slice().sort()).toEqual([...TARGETS, ...GITKEEPS].sort());
|
|
122
|
+
expect(injectedFs.mkdirSync).toHaveBeenCalled();
|
|
123
|
+
expect(injectedFs.writeFileSync).toHaveBeenCalledTimes(2);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('handles partially initialized local memory trees', () => {
|
|
127
|
+
fs.mkdirSync(path.join(projectDir, '.tlc', 'memory', '.local'), { recursive: true });
|
|
128
|
+
|
|
129
|
+
const result = ensureMemoryReady(projectDir);
|
|
130
|
+
|
|
131
|
+
expect(result.created.slice().sort()).toEqual([...TARGETS, ...GITKEEPS].sort());
|
|
132
|
+
expect(result.existing).toEqual([]);
|
|
133
|
+
expect(fs.existsSync(path.join(projectDir, '.tlc', 'memory', '.local', 'sessions'))).toBe(true);
|
|
134
|
+
});
|
|
135
|
+
});
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
const crypto = require('crypto');
|
|
2
|
+
|
|
3
|
+
function normalizeMessage(value) {
|
|
4
|
+
if (typeof value !== 'string') {
|
|
5
|
+
return '';
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
return value.trim();
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function createEnvelope({
|
|
12
|
+
provider,
|
|
13
|
+
source,
|
|
14
|
+
taskName,
|
|
15
|
+
threadId,
|
|
16
|
+
userMessage,
|
|
17
|
+
assistantMessage,
|
|
18
|
+
timestamp,
|
|
19
|
+
}) {
|
|
20
|
+
const normalizedUserMessage = normalizeMessage(userMessage);
|
|
21
|
+
const normalizedAssistantMessage = normalizeMessage(assistantMessage);
|
|
22
|
+
const parts = [normalizedUserMessage, normalizedAssistantMessage].filter(Boolean);
|
|
23
|
+
|
|
24
|
+
if (parts.length === 0) {
|
|
25
|
+
throw new Error('createEnvelope requires at least one non-empty message');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return {
|
|
29
|
+
provider,
|
|
30
|
+
source,
|
|
31
|
+
taskName,
|
|
32
|
+
threadId,
|
|
33
|
+
text: parts.join('\n\n'),
|
|
34
|
+
timestamp: typeof timestamp === 'string' ? timestamp : new Date().toISOString(),
|
|
35
|
+
metadata: {
|
|
36
|
+
userMessage,
|
|
37
|
+
assistantMessage,
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function validateEnvelope(obj) {
|
|
43
|
+
const errors = [];
|
|
44
|
+
const value = obj || {};
|
|
45
|
+
|
|
46
|
+
if (typeof value.provider !== 'string') {
|
|
47
|
+
errors.push('provider must be a string');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (typeof value.text !== 'string' || value.text.trim() === '') {
|
|
51
|
+
errors.push('text must be a non-empty string');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (typeof value.timestamp !== 'string') {
|
|
55
|
+
errors.push('timestamp must be a string');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
valid: errors.length === 0,
|
|
60
|
+
errors,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function idempotencyKey(envelope) {
|
|
65
|
+
const provider = typeof envelope.provider === 'string' ? envelope.provider : '';
|
|
66
|
+
const timestamp = typeof envelope.timestamp === 'string' ? envelope.timestamp : '';
|
|
67
|
+
const text = typeof envelope.text === 'string' ? envelope.text : '';
|
|
68
|
+
const payload = provider + timestamp + text.slice(0, 100);
|
|
69
|
+
|
|
70
|
+
return crypto.createHash('sha256').update(payload).digest('hex');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
module.exports = {
|
|
74
|
+
createEnvelope,
|
|
75
|
+
validateEnvelope,
|
|
76
|
+
idempotencyKey,
|
|
77
|
+
};
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import crypto from 'crypto';
|
|
3
|
+
|
|
4
|
+
describe('capture/envelope', () => {
|
|
5
|
+
let envelopeModule;
|
|
6
|
+
|
|
7
|
+
beforeEach(async () => {
|
|
8
|
+
vi.useFakeTimers();
|
|
9
|
+
vi.setSystemTime(new Date('2026-03-28T10:11:12.000Z'));
|
|
10
|
+
envelopeModule = await import('./envelope.js');
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
afterEach(() => {
|
|
14
|
+
vi.useRealTimers();
|
|
15
|
+
vi.resetModules();
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('creates a normalized envelope with combined text and metadata', () => {
|
|
19
|
+
const envelope = envelopeModule.createEnvelope({
|
|
20
|
+
provider: 'openai',
|
|
21
|
+
source: 'chat',
|
|
22
|
+
taskName: 'capture',
|
|
23
|
+
threadId: 'thread-1',
|
|
24
|
+
userMessage: 'User asks a question',
|
|
25
|
+
assistantMessage: 'Assistant answers it',
|
|
26
|
+
timestamp: '2026-03-27T00:00:00.000Z',
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
expect(envelope).toEqual({
|
|
30
|
+
provider: 'openai',
|
|
31
|
+
source: 'chat',
|
|
32
|
+
taskName: 'capture',
|
|
33
|
+
threadId: 'thread-1',
|
|
34
|
+
text: 'User asks a question\n\nAssistant answers it',
|
|
35
|
+
timestamp: '2026-03-27T00:00:00.000Z',
|
|
36
|
+
metadata: {
|
|
37
|
+
userMessage: 'User asks a question',
|
|
38
|
+
assistantMessage: 'Assistant answers it',
|
|
39
|
+
},
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('trims both messages before combining them', () => {
|
|
44
|
+
const envelope = envelopeModule.createEnvelope({
|
|
45
|
+
provider: 'openai',
|
|
46
|
+
userMessage: ' hello ',
|
|
47
|
+
assistantMessage: '\n world \n',
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
expect(envelope.text).toBe('hello\n\nworld');
|
|
51
|
+
expect(envelope.metadata).toEqual({
|
|
52
|
+
userMessage: ' hello ',
|
|
53
|
+
assistantMessage: '\n world \n',
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('supports an envelope with only a user message', () => {
|
|
58
|
+
const envelope = envelopeModule.createEnvelope({
|
|
59
|
+
provider: 'openai',
|
|
60
|
+
userMessage: 'Only user content',
|
|
61
|
+
assistantMessage: ' ',
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
expect(envelope.text).toBe('Only user content');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('supports an envelope with only an assistant message', () => {
|
|
68
|
+
const envelope = envelopeModule.createEnvelope({
|
|
69
|
+
provider: 'openai',
|
|
70
|
+
userMessage: '',
|
|
71
|
+
assistantMessage: 'Only assistant content',
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
expect(envelope.text).toBe('Only assistant content');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('defaults timestamp to the current ISO string', () => {
|
|
78
|
+
const envelope = envelopeModule.createEnvelope({
|
|
79
|
+
provider: 'openai',
|
|
80
|
+
userMessage: 'hello',
|
|
81
|
+
assistantMessage: 'world',
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
expect(envelope.timestamp).toBe('2026-03-28T10:11:12.000Z');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('throws when both messages are empty after trimming', () => {
|
|
88
|
+
expect(() => envelopeModule.createEnvelope({
|
|
89
|
+
provider: 'openai',
|
|
90
|
+
userMessage: ' ',
|
|
91
|
+
assistantMessage: '\n\t',
|
|
92
|
+
})).toThrow(/at least one/i);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('validateEnvelope returns valid for a complete minimal envelope', () => {
|
|
96
|
+
expect(envelopeModule.validateEnvelope({
|
|
97
|
+
provider: 'openai',
|
|
98
|
+
text: 'combined text',
|
|
99
|
+
timestamp: '2026-03-28T10:11:12.000Z',
|
|
100
|
+
})).toEqual({
|
|
101
|
+
valid: true,
|
|
102
|
+
errors: [],
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('validateEnvelope reports missing required fields', () => {
|
|
107
|
+
const result = envelopeModule.validateEnvelope({
|
|
108
|
+
provider: 42,
|
|
109
|
+
text: ' ',
|
|
110
|
+
timestamp: null,
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
expect(result.valid).toBe(false);
|
|
114
|
+
expect(result.errors).toContain('provider must be a string');
|
|
115
|
+
expect(result.errors).toContain('text must be a non-empty string');
|
|
116
|
+
expect(result.errors).toContain('timestamp must be a string');
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('validateEnvelope allows optional fields and metadata', () => {
|
|
120
|
+
const result = envelopeModule.validateEnvelope({
|
|
121
|
+
provider: 'openai',
|
|
122
|
+
source: 'cli',
|
|
123
|
+
taskName: 'summarize',
|
|
124
|
+
threadId: 'thread-9',
|
|
125
|
+
text: 'combined text',
|
|
126
|
+
timestamp: '2026-03-28T10:11:12.000Z',
|
|
127
|
+
metadata: {
|
|
128
|
+
userMessage: 'u',
|
|
129
|
+
assistantMessage: 'a',
|
|
130
|
+
},
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
expect(result).toEqual({
|
|
134
|
+
valid: true,
|
|
135
|
+
errors: [],
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('creates a stable idempotency key from provider timestamp and truncated text', () => {
|
|
140
|
+
const envelope = {
|
|
141
|
+
provider: 'openai',
|
|
142
|
+
timestamp: '2026-03-28T10:11:12.000Z',
|
|
143
|
+
text: 'x'.repeat(120),
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
const expected = crypto
|
|
147
|
+
.createHash('sha256')
|
|
148
|
+
.update(`openai2026-03-28T10:11:12.000Z${'x'.repeat(100)}`)
|
|
149
|
+
.digest('hex');
|
|
150
|
+
|
|
151
|
+
expect(envelopeModule.idempotencyKey(envelope)).toBe(expected);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('idempotency key ignores text beyond the first 100 characters', () => {
|
|
155
|
+
const prefix = 'p'.repeat(100);
|
|
156
|
+
const first = envelopeModule.idempotencyKey({
|
|
157
|
+
provider: 'openai',
|
|
158
|
+
timestamp: '2026-03-28T10:11:12.000Z',
|
|
159
|
+
text: `${prefix}AAA`,
|
|
160
|
+
});
|
|
161
|
+
const second = envelopeModule.idempotencyKey({
|
|
162
|
+
provider: 'openai',
|
|
163
|
+
timestamp: '2026-03-28T10:11:12.000Z',
|
|
164
|
+
text: `${prefix}BBB`,
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
expect(first).toBe(second);
|
|
168
|
+
});
|
|
169
|
+
});
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
const { classify } = require('./classifier.js');
|
|
2
|
+
|
|
3
|
+
function splitSentences(text) {
|
|
4
|
+
if (typeof text !== 'string' || !text.trim()) {
|
|
5
|
+
return [];
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
return text
|
|
9
|
+
.match(/[^.!?\n]+[.!?]?/g)
|
|
10
|
+
.map((sentence) => sentence.trim())
|
|
11
|
+
.filter(Boolean);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function extractDecisions(text, { role = 'unknown' } = {}) {
|
|
15
|
+
const sentences = splitSentences(text);
|
|
16
|
+
const seen = new Set();
|
|
17
|
+
const results = [];
|
|
18
|
+
|
|
19
|
+
for (const sentence of sentences) {
|
|
20
|
+
const classification = classify(sentence);
|
|
21
|
+
|
|
22
|
+
if (classification.type === 'session') {
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const statement = sentence;
|
|
27
|
+
|
|
28
|
+
if (seen.has(statement)) {
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
seen.add(statement);
|
|
33
|
+
|
|
34
|
+
const confidence = role === 'assistant'
|
|
35
|
+
? classification.confidence * 1.5
|
|
36
|
+
: classification.confidence;
|
|
37
|
+
|
|
38
|
+
results.push({
|
|
39
|
+
statement,
|
|
40
|
+
context: sentence,
|
|
41
|
+
confidence,
|
|
42
|
+
role,
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return results;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
module.exports = {
|
|
50
|
+
extractDecisions,
|
|
51
|
+
};
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
|
|
3
|
+
const { extractDecisions } = require('./extractor.js');
|
|
4
|
+
|
|
5
|
+
describe('capture/extractor', () => {
|
|
6
|
+
it('extracts a decision statement from explicit decision language', () => {
|
|
7
|
+
const results = extractDecisions('We decided to use Postgres for the event store.');
|
|
8
|
+
|
|
9
|
+
expect(results).toHaveLength(1);
|
|
10
|
+
expect(results[0]).toMatchObject({
|
|
11
|
+
statement: 'We decided to use Postgres for the event store.',
|
|
12
|
+
context: 'We decided to use Postgres for the event store.',
|
|
13
|
+
role: 'unknown',
|
|
14
|
+
});
|
|
15
|
+
expect(results[0].confidence).toBeGreaterThan(0);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('boosts confidence by 1.5x for assistant role', () => {
|
|
19
|
+
const text = 'We decided to use Postgres.';
|
|
20
|
+
|
|
21
|
+
const base = extractDecisions(text)[0];
|
|
22
|
+
const boosted = extractDecisions(text, { role: 'assistant' })[0];
|
|
23
|
+
|
|
24
|
+
expect(boosted.confidence).toBeCloseTo(base.confidence * 1.5, 5);
|
|
25
|
+
expect(boosted.role).toBe('assistant');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('extracts gotchas from warning phrases', () => {
|
|
29
|
+
const results = extractDecisions("Don't use synchronous fs calls in request handlers.");
|
|
30
|
+
|
|
31
|
+
expect(results).toHaveLength(1);
|
|
32
|
+
expect(results[0].statement).toBe("Don't use synchronous fs calls in request handlers.");
|
|
33
|
+
expect(results[0].context).toBe("Don't use synchronous fs calls in request handlers.");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('extracts preferences from personal preference phrases', () => {
|
|
37
|
+
const results = extractDecisions('I prefer dark theme for late-night debugging.');
|
|
38
|
+
|
|
39
|
+
expect(results).toHaveLength(1);
|
|
40
|
+
expect(results[0].statement).toBe('I prefer dark theme for late-night debugging.');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('returns an empty array when no phrases match', () => {
|
|
44
|
+
expect(extractDecisions('The server responded in 120ms and all tests passed.')).toEqual([]);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('dedupes repeated statements with the same text', () => {
|
|
48
|
+
const text = 'We decided to use Postgres. We decided to use Postgres.';
|
|
49
|
+
|
|
50
|
+
const results = extractDecisions(text);
|
|
51
|
+
|
|
52
|
+
expect(results).toHaveLength(1);
|
|
53
|
+
expect(results[0].statement).toBe('We decided to use Postgres.');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('extracts the containing sentence as context from multi-sentence input', () => {
|
|
57
|
+
const text = 'We reviewed the tradeoffs. The approach is to split into worker and api modules. Shipping stays on Friday.';
|
|
58
|
+
|
|
59
|
+
const results = extractDecisions(text);
|
|
60
|
+
|
|
61
|
+
expect(results).toHaveLength(1);
|
|
62
|
+
expect(results[0].statement).toBe('The approach is to split into worker and api modules.');
|
|
63
|
+
expect(results[0].context).toBe('The approach is to split into worker and api modules.');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('extracts multiple unique matches across decision, gotcha, and preference sentences', () => {
|
|
67
|
+
const text = [
|
|
68
|
+
'We decided to use Postgres.',
|
|
69
|
+
'Careful with long transactions on SQLite.',
|
|
70
|
+
'I like terminal-first workflows.',
|
|
71
|
+
].join(' ');
|
|
72
|
+
|
|
73
|
+
const results = extractDecisions(text);
|
|
74
|
+
|
|
75
|
+
expect(results).toHaveLength(3);
|
|
76
|
+
expect(results.map((entry) => entry.statement)).toEqual([
|
|
77
|
+
'We decided to use Postgres.',
|
|
78
|
+
'Careful with long transactions on SQLite.',
|
|
79
|
+
'I like terminal-first workflows.',
|
|
80
|
+
]);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('captures code-change style decision phrases such as created and implemented', () => {
|
|
84
|
+
const text = 'Created 3 modules for auth boundaries. Implemented request tracing for retries.';
|
|
85
|
+
|
|
86
|
+
const results = extractDecisions(text);
|
|
87
|
+
|
|
88
|
+
expect(results).toHaveLength(2);
|
|
89
|
+
expect(results[0].statement).toBe('Created 3 modules for auth boundaries.');
|
|
90
|
+
expect(results[1].statement).toBe('Implemented request tracing for retries.');
|
|
91
|
+
});
|
|
92
|
+
});
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
const path = require('path');
|
|
2
|
+
|
|
3
|
+
const { createEnvelope } = require('./envelope');
|
|
4
|
+
const { redact } = require('./redactor');
|
|
5
|
+
const { ensureMemoryReady } = require('./ensure-ready');
|
|
6
|
+
|
|
7
|
+
const SPOOL_PATH = path.join('.tlc', 'memory', '.spool.jsonl');
|
|
8
|
+
|
|
9
|
+
function appendEnvelope(projectDir, envelope, fs) {
|
|
10
|
+
ensureMemoryReady(projectDir, { fs });
|
|
11
|
+
|
|
12
|
+
const spoolPath = path.join(projectDir, SPOOL_PATH);
|
|
13
|
+
fs.appendFileSync(spoolPath, `${JSON.stringify(envelope)}\n`, 'utf8');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function createRedactedEnvelope({ text, provider, source, taskName }) {
|
|
17
|
+
const envelope = createEnvelope({
|
|
18
|
+
provider,
|
|
19
|
+
source,
|
|
20
|
+
taskName,
|
|
21
|
+
assistantMessage: text,
|
|
22
|
+
});
|
|
23
|
+
const redactedText = redact(envelope.text);
|
|
24
|
+
|
|
25
|
+
return {
|
|
26
|
+
...envelope,
|
|
27
|
+
text: redactedText,
|
|
28
|
+
metadata: {
|
|
29
|
+
...envelope.metadata,
|
|
30
|
+
assistantMessage: redact(envelope.metadata.assistantMessage || ''),
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function captureFromStdout({
|
|
36
|
+
stdout,
|
|
37
|
+
provider,
|
|
38
|
+
taskName,
|
|
39
|
+
projectDir,
|
|
40
|
+
fs = require('fs'),
|
|
41
|
+
}) {
|
|
42
|
+
try {
|
|
43
|
+
const envelope = createRedactedEnvelope({
|
|
44
|
+
text: stdout,
|
|
45
|
+
provider,
|
|
46
|
+
source: 'stdout',
|
|
47
|
+
taskName,
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
appendEnvelope(projectDir, envelope, fs);
|
|
51
|
+
|
|
52
|
+
return { captured: 1, skipped: 0 };
|
|
53
|
+
} catch (_) {
|
|
54
|
+
return { captured: 0, skipped: 1 };
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function extractResponseText(response) {
|
|
59
|
+
if (typeof response?.content === 'string') {
|
|
60
|
+
return response.content;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (typeof response?.choices?.[0]?.message?.content === 'string') {
|
|
64
|
+
return response.choices[0].message.content;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return JSON.stringify(response);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function captureFromApiResponse({
|
|
71
|
+
response,
|
|
72
|
+
provider,
|
|
73
|
+
taskName,
|
|
74
|
+
projectDir,
|
|
75
|
+
fs = require('fs'),
|
|
76
|
+
}) {
|
|
77
|
+
try {
|
|
78
|
+
const envelope = createRedactedEnvelope({
|
|
79
|
+
text: extractResponseText(response),
|
|
80
|
+
provider,
|
|
81
|
+
source: 'api-response',
|
|
82
|
+
taskName,
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
appendEnvelope(projectDir, envelope, fs);
|
|
86
|
+
|
|
87
|
+
return { captured: 1, skipped: 0 };
|
|
88
|
+
} catch (_) {
|
|
89
|
+
return { captured: 0, skipped: 1 };
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
module.exports = {
|
|
94
|
+
captureFromStdout,
|
|
95
|
+
captureFromApiResponse,
|
|
96
|
+
};
|