tlc-claude-code 2.0.1 → 2.1.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.
- package/.claude/commands/tlc/deploy.md +194 -2
- package/.claude/commands/tlc/e2e-verify.md +214 -0
- package/.claude/commands/tlc/guard.md +191 -0
- package/.claude/commands/tlc/help.md +32 -0
- package/.claude/commands/tlc/init.md +73 -37
- package/.claude/commands/tlc/llm.md +19 -4
- package/.claude/commands/tlc/preflight.md +134 -0
- package/.claude/commands/tlc/review.md +17 -4
- package/.claude/commands/tlc/watchci.md +159 -0
- package/.claude/hooks/tlc-block-tools.sh +41 -0
- package/.claude/hooks/tlc-capture-exchange.sh +50 -0
- package/.claude/hooks/tlc-post-build.sh +38 -0
- package/.claude/hooks/tlc-post-push.sh +22 -0
- package/.claude/hooks/tlc-prompt-guard.sh +69 -0
- package/.claude/hooks/tlc-session-init.sh +123 -0
- package/CLAUDE.md +12 -0
- package/bin/install.js +171 -2
- package/bin/postinstall.js +45 -26
- package/dashboard-web/dist/assets/index-CdS5CHqu.css +1 -0
- package/dashboard-web/dist/assets/index-CwNPPVpg.js +483 -0
- package/dashboard-web/dist/assets/index-CwNPPVpg.js.map +1 -0
- package/dashboard-web/dist/index.html +2 -2
- package/docker-compose.dev.yml +18 -12
- package/package.json +3 -1
- package/server/index.js +228 -2
- package/server/lib/capture-bridge.js +242 -0
- package/server/lib/capture-bridge.test.js +363 -0
- package/server/lib/capture-guard.js +140 -0
- package/server/lib/capture-guard.test.js +182 -0
- package/server/lib/command-runner.js +159 -0
- package/server/lib/command-runner.test.js +92 -0
- package/server/lib/deploy/runners/dependency-runner.js +106 -0
- package/server/lib/deploy/runners/dependency-runner.test.js +148 -0
- package/server/lib/deploy/runners/secrets-runner.js +174 -0
- package/server/lib/deploy/runners/secrets-runner.test.js +127 -0
- package/server/lib/deploy/security-gates.js +11 -24
- package/server/lib/deploy/security-gates.test.js +9 -2
- package/server/lib/deploy-engine.js +182 -0
- package/server/lib/deploy-engine.test.js +147 -0
- package/server/lib/docker-api.js +137 -0
- package/server/lib/docker-api.test.js +202 -0
- package/server/lib/docker-client.js +297 -0
- package/server/lib/docker-client.test.js +308 -0
- package/server/lib/input-sanitizer.js +86 -0
- package/server/lib/input-sanitizer.test.js +117 -0
- package/server/lib/launchd-agent.js +225 -0
- package/server/lib/launchd-agent.test.js +185 -0
- package/server/lib/memory-api.js +3 -1
- package/server/lib/memory-api.test.js +3 -5
- package/server/lib/memory-bridge-e2e.test.js +160 -0
- package/server/lib/memory-committer.js +18 -4
- package/server/lib/memory-committer.test.js +21 -0
- package/server/lib/memory-hooks-capture.test.js +69 -4
- package/server/lib/memory-hooks-integration.test.js +98 -0
- package/server/lib/memory-hooks.js +42 -4
- package/server/lib/memory-store-adapter.js +105 -0
- package/server/lib/memory-store-adapter.test.js +141 -0
- package/server/lib/memory-wiring-e2e.test.js +93 -0
- package/server/lib/nginx-config.js +114 -0
- package/server/lib/nginx-config.test.js +82 -0
- package/server/lib/ollama-health.js +91 -0
- package/server/lib/ollama-health.test.js +74 -0
- package/server/lib/port-guard.js +44 -0
- package/server/lib/port-guard.test.js +65 -0
- package/server/lib/project-scanner.js +37 -2
- package/server/lib/project-scanner.test.js +152 -0
- package/server/lib/remember-command.js +2 -0
- package/server/lib/remember-command.test.js +23 -0
- package/server/lib/security/crypto-utils.test.js +2 -2
- package/server/lib/semantic-recall.js +1 -1
- package/server/lib/semantic-recall.test.js +17 -0
- package/server/lib/ssh-client.js +184 -0
- package/server/lib/ssh-client.test.js +127 -0
- package/server/lib/vps-api.js +184 -0
- package/server/lib/vps-api.test.js +208 -0
- package/server/lib/vps-bootstrap.js +124 -0
- package/server/lib/vps-bootstrap.test.js +79 -0
- package/server/lib/vps-monitor.js +126 -0
- package/server/lib/vps-monitor.test.js +98 -0
- package/server/lib/workspace-api.js +182 -1
- package/server/lib/workspace-api.test.js +474 -0
- package/server/package-lock.json +737 -0
- package/server/package.json +3 -0
- package/dashboard-web/dist/assets/index-Uhc49PE-.css +0 -1
- package/dashboard-web/dist/assets/index-W36XHPC5.js +0 -431
- package/dashboard-web/dist/assets/index-W36XHPC5.js.map +0 -1
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LaunchAgent plist generator for macOS.
|
|
3
|
+
*
|
|
4
|
+
* Generates, installs, and manages a macOS LaunchAgent that keeps
|
|
5
|
+
* the TLC server running permanently — auto-starts on login,
|
|
6
|
+
* auto-restarts on crash.
|
|
7
|
+
*
|
|
8
|
+
* @module launchd-agent
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const fs = require('fs');
|
|
12
|
+
const path = require('path');
|
|
13
|
+
const os = require('os');
|
|
14
|
+
|
|
15
|
+
/** LaunchAgent label */
|
|
16
|
+
const PLIST_LABEL = 'com.tlc.server';
|
|
17
|
+
|
|
18
|
+
/** Default plist install location */
|
|
19
|
+
const PLIST_PATH = path.join(os.homedir(), 'Library', 'LaunchAgents', `${PLIST_LABEL}.plist`);
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Generate a macOS LaunchAgent plist XML string.
|
|
23
|
+
*
|
|
24
|
+
* @param {object} opts
|
|
25
|
+
* @param {string} opts.projectRoot - Absolute path to the TLC project
|
|
26
|
+
* @param {number} [opts.port=3147] - Server port
|
|
27
|
+
* @returns {string} Valid XML plist
|
|
28
|
+
*/
|
|
29
|
+
function generatePlist(opts) {
|
|
30
|
+
const { projectRoot, port = 3147 } = opts;
|
|
31
|
+
const nodePath = process.execPath;
|
|
32
|
+
const serverScript = path.join(projectRoot, 'server', 'index.js');
|
|
33
|
+
const logDir = path.join(os.homedir(), '.tlc', 'logs');
|
|
34
|
+
const logFile = path.join(logDir, 'server.log');
|
|
35
|
+
const envPath = process.env.PATH || '/usr/local/bin:/usr/bin:/bin';
|
|
36
|
+
|
|
37
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
38
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
39
|
+
<plist version="1.0">
|
|
40
|
+
<dict>
|
|
41
|
+
<key>Label</key>
|
|
42
|
+
<string>${PLIST_LABEL}</string>
|
|
43
|
+
|
|
44
|
+
<key>ProgramArguments</key>
|
|
45
|
+
<array>
|
|
46
|
+
<string>${nodePath}</string>
|
|
47
|
+
<string>${serverScript}</string>
|
|
48
|
+
</array>
|
|
49
|
+
|
|
50
|
+
<key>WorkingDirectory</key>
|
|
51
|
+
<string>${projectRoot}</string>
|
|
52
|
+
|
|
53
|
+
<key>EnvironmentVariables</key>
|
|
54
|
+
<dict>
|
|
55
|
+
<key>PATH</key>
|
|
56
|
+
<string>${envPath}</string>
|
|
57
|
+
<key>HOME</key>
|
|
58
|
+
<string>${os.homedir()}</string>
|
|
59
|
+
<key>NODE_ENV</key>
|
|
60
|
+
<string>development</string>
|
|
61
|
+
<key>TLC_PORT</key>
|
|
62
|
+
<string>${port}</string>
|
|
63
|
+
</dict>
|
|
64
|
+
|
|
65
|
+
<key>KeepAlive</key>
|
|
66
|
+
<true/>
|
|
67
|
+
|
|
68
|
+
<key>ThrottleInterval</key>
|
|
69
|
+
<integer>10</integer>
|
|
70
|
+
|
|
71
|
+
<key>StandardOutPath</key>
|
|
72
|
+
<string>${logFile}</string>
|
|
73
|
+
|
|
74
|
+
<key>StandardErrorPath</key>
|
|
75
|
+
<string>${logFile}</string>
|
|
76
|
+
</dict>
|
|
77
|
+
</plist>
|
|
78
|
+
`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Write the plist file to disk.
|
|
83
|
+
*
|
|
84
|
+
* @param {object} opts
|
|
85
|
+
* @param {string} opts.projectRoot - Absolute path to the TLC project
|
|
86
|
+
* @param {string} [opts.targetDir] - Override install directory (for testing)
|
|
87
|
+
* @param {number} [opts.port=3147] - Server port
|
|
88
|
+
*/
|
|
89
|
+
function installAgent(opts) {
|
|
90
|
+
const targetDir = opts.targetDir || path.join(os.homedir(), 'Library', 'LaunchAgents');
|
|
91
|
+
const plistPath = path.join(targetDir, `${PLIST_LABEL}.plist`);
|
|
92
|
+
|
|
93
|
+
// Ensure log directory exists
|
|
94
|
+
const logDir = path.join(os.homedir(), '.tlc', 'logs');
|
|
95
|
+
if (!fs.existsSync(logDir)) {
|
|
96
|
+
fs.mkdirSync(logDir, { recursive: true });
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Ensure target directory exists
|
|
100
|
+
if (!fs.existsSync(targetDir)) {
|
|
101
|
+
fs.mkdirSync(targetDir, { recursive: true });
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const xml = generatePlist(opts);
|
|
105
|
+
fs.writeFileSync(plistPath, xml, 'utf-8');
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Remove the plist file from disk.
|
|
110
|
+
*
|
|
111
|
+
* @param {object} [opts]
|
|
112
|
+
* @param {string} [opts.targetDir] - Override install directory (for testing)
|
|
113
|
+
*/
|
|
114
|
+
function uninstallAgent(opts = {}) {
|
|
115
|
+
const targetDir = opts.targetDir || path.join(os.homedir(), 'Library', 'LaunchAgents');
|
|
116
|
+
const plistPath = path.join(targetDir, `${PLIST_LABEL}.plist`);
|
|
117
|
+
|
|
118
|
+
if (fs.existsSync(plistPath)) {
|
|
119
|
+
fs.unlinkSync(plistPath);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Check if the plist file is installed.
|
|
125
|
+
*
|
|
126
|
+
* @param {object} [opts]
|
|
127
|
+
* @param {string} [opts.targetDir] - Override install directory (for testing)
|
|
128
|
+
* @returns {boolean}
|
|
129
|
+
*/
|
|
130
|
+
function isInstalled(opts = {}) {
|
|
131
|
+
const targetDir = opts.targetDir || path.join(os.homedir(), 'Library', 'LaunchAgents');
|
|
132
|
+
const plistPath = path.join(targetDir, `${PLIST_LABEL}.plist`);
|
|
133
|
+
return fs.existsSync(plistPath);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Load the agent into launchd.
|
|
138
|
+
*
|
|
139
|
+
* @param {object} [opts]
|
|
140
|
+
* @param {Function} [opts.exec] - Async exec function (for testing)
|
|
141
|
+
* @param {string} [opts.targetDir] - Override install directory
|
|
142
|
+
* @returns {Promise<{ok: boolean, error?: string}>}
|
|
143
|
+
*/
|
|
144
|
+
async function loadAgent(opts = {}) {
|
|
145
|
+
const exec = opts.exec || defaultExec;
|
|
146
|
+
const targetDir = opts.targetDir || path.join(os.homedir(), 'Library', 'LaunchAgents');
|
|
147
|
+
const plistPath = path.join(targetDir, `${PLIST_LABEL}.plist`);
|
|
148
|
+
|
|
149
|
+
try {
|
|
150
|
+
await exec(`launchctl load -w ${plistPath}`);
|
|
151
|
+
return { ok: true };
|
|
152
|
+
} catch (err) {
|
|
153
|
+
return { ok: false, error: err.message };
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Unload the agent from launchd and remove the plist.
|
|
159
|
+
*
|
|
160
|
+
* @param {object} [opts]
|
|
161
|
+
* @param {Function} [opts.exec] - Async exec function (for testing)
|
|
162
|
+
* @param {string} [opts.targetDir] - Override install directory
|
|
163
|
+
* @returns {Promise<{ok: boolean, error?: string}>}
|
|
164
|
+
*/
|
|
165
|
+
async function unloadAgent(opts = {}) {
|
|
166
|
+
const exec = opts.exec || defaultExec;
|
|
167
|
+
const targetDir = opts.targetDir || path.join(os.homedir(), 'Library', 'LaunchAgents');
|
|
168
|
+
const plistPath = path.join(targetDir, `${PLIST_LABEL}.plist`);
|
|
169
|
+
|
|
170
|
+
try {
|
|
171
|
+
await exec(`launchctl unload ${plistPath}`);
|
|
172
|
+
} catch {
|
|
173
|
+
// Agent may not be loaded — that's fine
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
uninstallAgent({ targetDir });
|
|
177
|
+
return { ok: true };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Check if the agent is loaded in launchd.
|
|
182
|
+
*
|
|
183
|
+
* @param {object} [opts]
|
|
184
|
+
* @param {Function} [opts.exec] - Async exec function (for testing)
|
|
185
|
+
* @returns {Promise<{loaded: boolean, pid?: number}>}
|
|
186
|
+
*/
|
|
187
|
+
async function statusAgent(opts = {}) {
|
|
188
|
+
const exec = opts.exec || defaultExec;
|
|
189
|
+
|
|
190
|
+
try {
|
|
191
|
+
const { stdout } = await exec(`launchctl list ${PLIST_LABEL}`);
|
|
192
|
+
const parts = stdout.trim().split('\t');
|
|
193
|
+
const pid = parts[0] && parts[0] !== '-' ? parseInt(parts[0], 10) : null;
|
|
194
|
+
return { loaded: true, pid };
|
|
195
|
+
} catch {
|
|
196
|
+
return { loaded: false };
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Default exec using child_process.
|
|
202
|
+
* @param {string} cmd
|
|
203
|
+
* @returns {Promise<{stdout: string, stderr: string}>}
|
|
204
|
+
*/
|
|
205
|
+
function defaultExec(cmd) {
|
|
206
|
+
const { exec } = require('child_process');
|
|
207
|
+
return new Promise((resolve, reject) => {
|
|
208
|
+
exec(cmd, (err, stdout, stderr) => {
|
|
209
|
+
if (err) return reject(err);
|
|
210
|
+
resolve({ stdout, stderr });
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
module.exports = {
|
|
216
|
+
generatePlist,
|
|
217
|
+
installAgent,
|
|
218
|
+
uninstallAgent,
|
|
219
|
+
isInstalled,
|
|
220
|
+
loadAgent,
|
|
221
|
+
unloadAgent,
|
|
222
|
+
statusAgent,
|
|
223
|
+
PLIST_LABEL,
|
|
224
|
+
PLIST_PATH,
|
|
225
|
+
};
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LaunchAgent plist generator tests - Phase 83 Task 1
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
6
|
+
import path from 'path';
|
|
7
|
+
import os from 'os';
|
|
8
|
+
import fs from 'fs';
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
generatePlist,
|
|
12
|
+
installAgent,
|
|
13
|
+
uninstallAgent,
|
|
14
|
+
isInstalled,
|
|
15
|
+
loadAgent,
|
|
16
|
+
unloadAgent,
|
|
17
|
+
statusAgent,
|
|
18
|
+
PLIST_LABEL,
|
|
19
|
+
PLIST_PATH,
|
|
20
|
+
} from './launchd-agent.js';
|
|
21
|
+
|
|
22
|
+
describe('launchd-agent', () => {
|
|
23
|
+
describe('generatePlist', () => {
|
|
24
|
+
it('returns valid XML with correct label', () => {
|
|
25
|
+
const xml = generatePlist({ projectRoot: '/Users/dev/myproject' });
|
|
26
|
+
expect(xml).toContain('<?xml version="1.0"');
|
|
27
|
+
expect(xml).toContain('<!DOCTYPE plist');
|
|
28
|
+
expect(xml).toContain('<string>com.tlc.server</string>');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('includes KeepAlive and ThrottleInterval', () => {
|
|
32
|
+
const xml = generatePlist({ projectRoot: '/Users/dev/myproject' });
|
|
33
|
+
expect(xml).toContain('<key>KeepAlive</key>');
|
|
34
|
+
expect(xml).toContain('<true/>');
|
|
35
|
+
expect(xml).toContain('<key>ThrottleInterval</key>');
|
|
36
|
+
expect(xml).toContain('<integer>10</integer>');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('sets WorkingDirectory to project root', () => {
|
|
40
|
+
const xml = generatePlist({ projectRoot: '/Users/dev/myproject' });
|
|
41
|
+
expect(xml).toContain('<key>WorkingDirectory</key>');
|
|
42
|
+
expect(xml).toContain('<string>/Users/dev/myproject</string>');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('sets EnvironmentVariables including PATH and NODE_ENV', () => {
|
|
46
|
+
const xml = generatePlist({ projectRoot: '/Users/dev/myproject' });
|
|
47
|
+
expect(xml).toContain('<key>EnvironmentVariables</key>');
|
|
48
|
+
expect(xml).toContain('<key>NODE_ENV</key>');
|
|
49
|
+
expect(xml).toContain('<string>development</string>');
|
|
50
|
+
expect(xml).toContain('<key>PATH</key>');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('uses absolute node path in ProgramArguments', () => {
|
|
54
|
+
const xml = generatePlist({ projectRoot: '/Users/dev/myproject' });
|
|
55
|
+
expect(xml).toContain('<key>ProgramArguments</key>');
|
|
56
|
+
// Should contain path to node and path to server/index.js
|
|
57
|
+
expect(xml).toContain('server/index.js');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('sets log paths under ~/.tlc/logs/', () => {
|
|
61
|
+
const xml = generatePlist({ projectRoot: '/Users/dev/myproject' });
|
|
62
|
+
expect(xml).toContain('<key>StandardOutPath</key>');
|
|
63
|
+
expect(xml).toContain('<key>StandardErrorPath</key>');
|
|
64
|
+
expect(xml).toContain('.tlc/logs/server.log');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('allows custom port via opts', () => {
|
|
68
|
+
const xml = generatePlist({ projectRoot: '/Users/dev/myproject', port: 4000 });
|
|
69
|
+
expect(xml).toContain('<key>TLC_PORT</key>');
|
|
70
|
+
expect(xml).toContain('<string>4000</string>');
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe('installAgent', () => {
|
|
75
|
+
let tmpDir;
|
|
76
|
+
|
|
77
|
+
beforeEach(() => {
|
|
78
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tlc-launchd-'));
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
afterEach(() => {
|
|
82
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('writes plist file to target directory', () => {
|
|
86
|
+
const plistPath = path.join(tmpDir, 'com.tlc.server.plist');
|
|
87
|
+
installAgent({ projectRoot: '/Users/dev/myproject', targetDir: tmpDir });
|
|
88
|
+
expect(fs.existsSync(plistPath)).toBe(true);
|
|
89
|
+
const content = fs.readFileSync(plistPath, 'utf-8');
|
|
90
|
+
expect(content).toContain('com.tlc.server');
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe('uninstallAgent', () => {
|
|
95
|
+
let tmpDir;
|
|
96
|
+
|
|
97
|
+
beforeEach(() => {
|
|
98
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tlc-launchd-'));
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
afterEach(() => {
|
|
102
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('removes plist file', () => {
|
|
106
|
+
const plistPath = path.join(tmpDir, 'com.tlc.server.plist');
|
|
107
|
+
fs.writeFileSync(plistPath, '<plist/>');
|
|
108
|
+
uninstallAgent({ targetDir: tmpDir });
|
|
109
|
+
expect(fs.existsSync(plistPath)).toBe(false);
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
describe('isInstalled', () => {
|
|
114
|
+
let tmpDir;
|
|
115
|
+
|
|
116
|
+
beforeEach(() => {
|
|
117
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tlc-launchd-'));
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
afterEach(() => {
|
|
121
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('returns true when plist exists', () => {
|
|
125
|
+
fs.writeFileSync(path.join(tmpDir, 'com.tlc.server.plist'), '<plist/>');
|
|
126
|
+
expect(isInstalled({ targetDir: tmpDir })).toBe(true);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('returns false when plist does not exist', () => {
|
|
130
|
+
expect(isInstalled({ targetDir: tmpDir })).toBe(false);
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
describe('loadAgent', () => {
|
|
135
|
+
it('calls launchctl load with correct path', async () => {
|
|
136
|
+
const mockExec = vi.fn().mockResolvedValue({ stdout: '', stderr: '' });
|
|
137
|
+
const result = await loadAgent({ exec: mockExec, targetDir: '/tmp/agents' });
|
|
138
|
+
expect(mockExec).toHaveBeenCalledWith(
|
|
139
|
+
expect.stringContaining('launchctl load'),
|
|
140
|
+
);
|
|
141
|
+
expect(mockExec).toHaveBeenCalledWith(
|
|
142
|
+
expect.stringContaining('com.tlc.server.plist'),
|
|
143
|
+
);
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
describe('unloadAgent', () => {
|
|
148
|
+
it('calls launchctl unload then removes plist', async () => {
|
|
149
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tlc-launchd-'));
|
|
150
|
+
const plistPath = path.join(tmpDir, 'com.tlc.server.plist');
|
|
151
|
+
fs.writeFileSync(plistPath, '<plist/>');
|
|
152
|
+
|
|
153
|
+
const mockExec = vi.fn().mockResolvedValue({ stdout: '', stderr: '' });
|
|
154
|
+
await unloadAgent({ exec: mockExec, targetDir: tmpDir });
|
|
155
|
+
|
|
156
|
+
expect(mockExec).toHaveBeenCalledWith(
|
|
157
|
+
expect.stringContaining('launchctl unload'),
|
|
158
|
+
);
|
|
159
|
+
expect(fs.existsSync(plistPath)).toBe(false);
|
|
160
|
+
|
|
161
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
describe('statusAgent', () => {
|
|
166
|
+
it('returns loaded when launchctl finds the agent', async () => {
|
|
167
|
+
const mockExec = vi.fn().mockResolvedValue({ stdout: '12345\t0\tcom.tlc.server', stderr: '' });
|
|
168
|
+
const result = await statusAgent({ exec: mockExec });
|
|
169
|
+
expect(result.loaded).toBe(true);
|
|
170
|
+
expect(result.pid).toBeDefined();
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('returns not loaded when launchctl does not find agent', async () => {
|
|
174
|
+
const mockExec = vi.fn().mockRejectedValue(new Error('Could not find service'));
|
|
175
|
+
const result = await statusAgent({ exec: mockExec });
|
|
176
|
+
expect(result.loaded).toBe(false);
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
describe('constants', () => {
|
|
181
|
+
it('exports correct plist label', () => {
|
|
182
|
+
expect(PLIST_LABEL).toBe('com.tlc.server');
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
});
|
package/server/lib/memory-api.js
CHANGED
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
* @param {object} deps.memoryStore - Memory store with list/get methods
|
|
29
29
|
* @returns {object} Handler methods for each memory endpoint
|
|
30
30
|
*/
|
|
31
|
-
|
|
31
|
+
function createMemoryApi({ semanticRecall, vectorIndexer, richCapture, embeddingClient, memoryStore }) {
|
|
32
32
|
|
|
33
33
|
/**
|
|
34
34
|
* Search memory semantically.
|
|
@@ -178,3 +178,5 @@ export function createMemoryApi({ semanticRecall, vectorIndexer, richCapture, em
|
|
|
178
178
|
handleRemember,
|
|
179
179
|
};
|
|
180
180
|
}
|
|
181
|
+
|
|
182
|
+
module.exports = { createMemoryApi };
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @file memory-api.test.js
|
|
3
|
-
* @description Tests for the Memory API endpoint handlers
|
|
3
|
+
* @description Tests for the Memory API endpoint handlers.
|
|
4
4
|
*
|
|
5
5
|
* Tests the factory function `createMemoryApi(deps)` which accepts injected
|
|
6
6
|
* dependencies (semanticRecall, vectorIndexer, richCapture, embeddingClient,
|
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
* routing is tested here.
|
|
12
12
|
*/
|
|
13
13
|
import { describe, it, beforeEach, expect, vi } from 'vitest';
|
|
14
|
-
|
|
14
|
+
|
|
15
|
+
const { createMemoryApi } = await import('./memory-api.js');
|
|
15
16
|
|
|
16
17
|
/**
|
|
17
18
|
* Creates a mock Express request object.
|
|
@@ -294,7 +295,6 @@ describe('memory-api', () => {
|
|
|
294
295
|
it('project filter works across endpoints', async () => {
|
|
295
296
|
const projectId = 'proj-abc';
|
|
296
297
|
|
|
297
|
-
// Test project filter on conversations
|
|
298
298
|
const convReq = createMockReq({ query: { project: projectId, page: '1', limit: '10' } });
|
|
299
299
|
const convRes = createMockRes();
|
|
300
300
|
await api.handleListConversations(convReq, convRes);
|
|
@@ -302,7 +302,6 @@ describe('memory-api', () => {
|
|
|
302
302
|
expect.objectContaining({ project: projectId })
|
|
303
303
|
);
|
|
304
304
|
|
|
305
|
-
// Test project filter on decisions
|
|
306
305
|
const decReq = createMockReq({ query: { project: projectId } });
|
|
307
306
|
const decRes = createMockRes();
|
|
308
307
|
await api.handleListDecisions(decReq, decRes);
|
|
@@ -310,7 +309,6 @@ describe('memory-api', () => {
|
|
|
310
309
|
expect.objectContaining({ project: projectId })
|
|
311
310
|
);
|
|
312
311
|
|
|
313
|
-
// Test project filter on gotchas
|
|
314
312
|
const gotReq = createMockReq({ query: { project: projectId } });
|
|
315
313
|
const gotRes = createMockRes();
|
|
316
314
|
await api.handleListGotchas(gotReq, gotRes);
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Memory Bridge E2E Tests - Phase 82 Task 5
|
|
3
|
+
*
|
|
4
|
+
* Tests the full pipeline: capture → observeAndRemember → pattern detect → file store.
|
|
5
|
+
* Proves the memory system achieves its original goal.
|
|
6
|
+
*
|
|
7
|
+
* RED: depends on capture-bridge.js (Task 1) and capture-guard.js (Task 4).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
|
|
11
|
+
import fs from 'fs';
|
|
12
|
+
import path from 'path';
|
|
13
|
+
import os from 'os';
|
|
14
|
+
|
|
15
|
+
import { captureExchange, drainSpool, SPOOL_FILENAME } from './capture-bridge.js';
|
|
16
|
+
import { observeAndRemember } from './memory-observer.js';
|
|
17
|
+
|
|
18
|
+
describe('memory-bridge e2e', () => {
|
|
19
|
+
let testDir;
|
|
20
|
+
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tlc-bridge-e2e-'));
|
|
23
|
+
// Create memory directory structure
|
|
24
|
+
fs.mkdirSync(path.join(testDir, '.tlc', 'memory', 'team', 'decisions'), { recursive: true });
|
|
25
|
+
fs.mkdirSync(path.join(testDir, '.tlc', 'memory', 'team', 'gotchas'), { recursive: true });
|
|
26
|
+
fs.mkdirSync(path.join(testDir, '.tlc', 'memory', '.local'), { recursive: true });
|
|
27
|
+
fs.writeFileSync(path.join(testDir, '.tlc.json'), JSON.stringify({ project: 'e2e-test' }));
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
afterEach(() => {
|
|
31
|
+
fs.rmSync(testDir, { recursive: true, force: true });
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('decision in exchange creates decision file', async () => {
|
|
35
|
+
// Pattern detector analyzes the user field for decision patterns
|
|
36
|
+
const exchange = {
|
|
37
|
+
user: "let's use PostgreSQL instead of MySQL because we need JSONB support.",
|
|
38
|
+
assistant: 'Good choice. PostgreSQL has excellent JSONB support.',
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
await observeAndRemember(testDir, exchange);
|
|
42
|
+
|
|
43
|
+
// Wait for setImmediate-based async processing
|
|
44
|
+
await new Promise(resolve => setTimeout(resolve, 500));
|
|
45
|
+
|
|
46
|
+
// Check that a decision file was created
|
|
47
|
+
const decisionsDir = path.join(testDir, '.tlc', 'memory', 'team', 'decisions');
|
|
48
|
+
const files = fs.readdirSync(decisionsDir);
|
|
49
|
+
expect(files.length).toBeGreaterThanOrEqual(1);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('gotcha in exchange creates gotcha file', async () => {
|
|
53
|
+
// Pattern detector looks for "watch out for X" in user field
|
|
54
|
+
const exchange = {
|
|
55
|
+
user: 'watch out for the PGlite WASM driver under concurrent writes.',
|
|
56
|
+
assistant: 'Good catch. Serialize database operations to avoid crashes.',
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
await observeAndRemember(testDir, exchange);
|
|
60
|
+
|
|
61
|
+
await new Promise(resolve => setTimeout(resolve, 500));
|
|
62
|
+
|
|
63
|
+
const gotchasDir = path.join(testDir, '.tlc', 'memory', 'team', 'gotchas');
|
|
64
|
+
const files = fs.readdirSync(gotchasDir);
|
|
65
|
+
expect(files.length).toBeGreaterThanOrEqual(1);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('full pipeline: captureExchange → observe → file stored', async () => {
|
|
69
|
+
// Simulate what the Stop hook does: POST to a mock server that calls observeAndRemember
|
|
70
|
+
let capturedExchange = null;
|
|
71
|
+
|
|
72
|
+
// Mock fetch that simulates the server calling observeAndRemember
|
|
73
|
+
const mockFetch = vi.fn().mockImplementation(async (url, opts) => {
|
|
74
|
+
const body = JSON.parse(opts.body);
|
|
75
|
+
for (const ex of body.exchanges) {
|
|
76
|
+
capturedExchange = ex;
|
|
77
|
+
await observeAndRemember(testDir, ex);
|
|
78
|
+
}
|
|
79
|
+
return { ok: true, json: async () => ({ captured: body.exchanges.length }) };
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
await captureExchange({
|
|
83
|
+
cwd: testDir,
|
|
84
|
+
// Pattern detector analyzes user field — put decision language there
|
|
85
|
+
assistantMessage: 'Good choice, JWT is better for horizontal scaling.',
|
|
86
|
+
userMessage: "let's use JWT tokens instead of sessions for authentication.",
|
|
87
|
+
sessionId: 'e2e-sess-1',
|
|
88
|
+
}, { fetch: mockFetch });
|
|
89
|
+
|
|
90
|
+
await new Promise(resolve => setTimeout(resolve, 300));
|
|
91
|
+
|
|
92
|
+
// Verify the exchange was captured
|
|
93
|
+
expect(capturedExchange).not.toBeNull();
|
|
94
|
+
expect(capturedExchange.user).toContain('JWT tokens');
|
|
95
|
+
|
|
96
|
+
// Verify a decision file was created
|
|
97
|
+
const decisionsDir = path.join(testDir, '.tlc', 'memory', 'team', 'decisions');
|
|
98
|
+
const files = fs.readdirSync(decisionsDir);
|
|
99
|
+
expect(files.length).toBeGreaterThanOrEqual(1);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('spool entry captured after drain', async () => {
|
|
103
|
+
const spoolDir = path.join(testDir, '.tlc', 'memory');
|
|
104
|
+
const spoolPath = path.join(spoolDir, SPOOL_FILENAME);
|
|
105
|
+
|
|
106
|
+
// Write a spooled entry with a decision (pattern in user field)
|
|
107
|
+
const spooledEntry = JSON.stringify({
|
|
108
|
+
projectId: 'e2e-test',
|
|
109
|
+
exchanges: [{
|
|
110
|
+
user: "we decided to use SQLite for the vector store.",
|
|
111
|
+
assistant: 'SQLite embeds directly and needs no separate process.',
|
|
112
|
+
timestamp: Date.now(),
|
|
113
|
+
}],
|
|
114
|
+
});
|
|
115
|
+
fs.writeFileSync(spoolPath, spooledEntry + '\n');
|
|
116
|
+
|
|
117
|
+
// Mock fetch that calls observeAndRemember (like the real server would)
|
|
118
|
+
const mockFetch = vi.fn().mockImplementation(async (url, opts) => {
|
|
119
|
+
const body = JSON.parse(opts.body);
|
|
120
|
+
for (const ex of body.exchanges) {
|
|
121
|
+
await observeAndRemember(testDir, ex);
|
|
122
|
+
}
|
|
123
|
+
return { ok: true, json: async () => ({ captured: body.exchanges.length }) };
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
await drainSpool(spoolDir, { fetch: mockFetch });
|
|
127
|
+
|
|
128
|
+
await new Promise(resolve => setTimeout(resolve, 500));
|
|
129
|
+
|
|
130
|
+
// Spool should be drained
|
|
131
|
+
if (fs.existsSync(spoolPath)) {
|
|
132
|
+
expect(fs.readFileSync(spoolPath, 'utf-8').trim()).toBe('');
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Decision file should have been created from the spooled exchange
|
|
136
|
+
const decisionsDir = path.join(testDir, '.tlc', 'memory', 'team', 'decisions');
|
|
137
|
+
const files = fs.readdirSync(decisionsDir);
|
|
138
|
+
expect(files.length).toBeGreaterThanOrEqual(1);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('capture guard deduplicates identical exchanges', async () => {
|
|
142
|
+
// Dedup happens at the capture guard level, not the observer
|
|
143
|
+
const { createCaptureGuard } = await import('./capture-guard.js');
|
|
144
|
+
const guard = createCaptureGuard();
|
|
145
|
+
|
|
146
|
+
const exchange = {
|
|
147
|
+
user: "we decided to use Redis as our caching layer.",
|
|
148
|
+
assistant: 'Redis is great for caching.',
|
|
149
|
+
timestamp: Date.now(),
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
// First call returns the exchange
|
|
153
|
+
const first = guard.deduplicate([exchange], 'e2e-test');
|
|
154
|
+
expect(first).toHaveLength(1);
|
|
155
|
+
|
|
156
|
+
// Same exchange immediately — deduplicated
|
|
157
|
+
const second = guard.deduplicate([exchange], 'e2e-test');
|
|
158
|
+
expect(second).toHaveLength(0);
|
|
159
|
+
});
|
|
160
|
+
});
|
|
@@ -23,7 +23,24 @@ async function detectUncommittedMemory(projectRoot) {
|
|
|
23
23
|
return [];
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
//
|
|
26
|
+
// Try git status first — only return modified/untracked files
|
|
27
|
+
try {
|
|
28
|
+
const teamRelative = path.relative(projectRoot, teamDir);
|
|
29
|
+
const { stdout } = await execAsync(
|
|
30
|
+
`git status --porcelain -- "${teamRelative}"`,
|
|
31
|
+
{ cwd: projectRoot }
|
|
32
|
+
);
|
|
33
|
+
if (stdout.trim().length === 0) return [];
|
|
34
|
+
|
|
35
|
+
return stdout.trim().split('\n')
|
|
36
|
+
.map(line => line.slice(3).trim()) // strip status prefix (e.g. "?? ", " M ")
|
|
37
|
+
.filter(f => f.endsWith('.json') || f.endsWith('.md'))
|
|
38
|
+
.filter(f => !f.endsWith('conventions.md'));
|
|
39
|
+
} catch {
|
|
40
|
+
// Not a git repo or git not available — fall back to walkDir
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Fallback: return all files (non-git directory)
|
|
27
44
|
const files = [];
|
|
28
45
|
|
|
29
46
|
async function walkDir(dir) {
|
|
@@ -33,10 +50,7 @@ async function detectUncommittedMemory(projectRoot) {
|
|
|
33
50
|
if (entry.isDirectory()) {
|
|
34
51
|
await walkDir(fullPath);
|
|
35
52
|
} else if (entry.name.endsWith('.json') || entry.name.endsWith('.md')) {
|
|
36
|
-
// Skip template files like conventions.md
|
|
37
53
|
if (entry.name === 'conventions.md') continue;
|
|
38
|
-
|
|
39
|
-
// Get path relative to projectRoot
|
|
40
54
|
const relativePath = path.relative(projectRoot, fullPath);
|
|
41
55
|
files.push(relativePath);
|
|
42
56
|
}
|
|
@@ -58,6 +58,27 @@ describe('memory-committer', () => {
|
|
|
58
58
|
|
|
59
59
|
expect(uncommitted.every(f => !f.includes('.local'))).toBe(true);
|
|
60
60
|
});
|
|
61
|
+
|
|
62
|
+
// Phase 81 Task 4: detectUncommittedMemory should use git status
|
|
63
|
+
it('returns empty for already-committed files in a git repo', async () => {
|
|
64
|
+
// Create a git repo, add a decision, and commit it
|
|
65
|
+
const { execSync } = await import('child_process');
|
|
66
|
+
execSync('git init', { cwd: testDir, stdio: 'pipe' });
|
|
67
|
+
execSync('git config user.email "test@test.com"', { cwd: testDir, stdio: 'pipe' });
|
|
68
|
+
execSync('git config user.name "Test"', { cwd: testDir, stdio: 'pipe' });
|
|
69
|
+
|
|
70
|
+
await writeTeamDecision(testDir, {
|
|
71
|
+
title: 'Committed Decision',
|
|
72
|
+
reasoning: 'Already committed',
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
execSync('git add -A', { cwd: testDir, stdio: 'pipe' });
|
|
76
|
+
execSync('git commit -m "initial"', { cwd: testDir, stdio: 'pipe' });
|
|
77
|
+
|
|
78
|
+
// Now detect uncommitted — should be empty since everything is committed
|
|
79
|
+
const uncommitted = await detectUncommittedMemory(testDir);
|
|
80
|
+
expect(uncommitted).toHaveLength(0);
|
|
81
|
+
});
|
|
61
82
|
});
|
|
62
83
|
|
|
63
84
|
describe('generateCommitMessage', () => {
|