fe-harness 1.0.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.
Files changed (41) hide show
  1. package/README.md +55 -0
  2. package/agents/fe-codebase-mapper.md +945 -0
  3. package/agents/fe-design-scanner.md +47 -0
  4. package/agents/fe-executor.md +221 -0
  5. package/agents/fe-fix-loop.md +310 -0
  6. package/agents/fe-fixer.md +153 -0
  7. package/agents/fe-project-scanner.md +95 -0
  8. package/agents/fe-reviewer.md +141 -0
  9. package/agents/fe-verifier.md +231 -0
  10. package/agents/fe-wave-runner.md +477 -0
  11. package/bin/install.js +292 -0
  12. package/commands/fe/complete.md +35 -0
  13. package/commands/fe/execute.md +46 -0
  14. package/commands/fe/help.md +17 -0
  15. package/commands/fe/map-codebase.md +60 -0
  16. package/commands/fe/plan.md +36 -0
  17. package/commands/fe/status.md +39 -0
  18. package/fe-harness/bin/browser.cjs +271 -0
  19. package/fe-harness/bin/fe-tools.cjs +317 -0
  20. package/fe-harness/bin/lib/__tests__/browser.test.cjs +422 -0
  21. package/fe-harness/bin/lib/__tests__/config.test.cjs +93 -0
  22. package/fe-harness/bin/lib/__tests__/core.test.cjs +127 -0
  23. package/fe-harness/bin/lib/__tests__/scoring.test.cjs +130 -0
  24. package/fe-harness/bin/lib/__tests__/tasks.test.cjs +698 -0
  25. package/fe-harness/bin/lib/browser-core.cjs +365 -0
  26. package/fe-harness/bin/lib/config.cjs +34 -0
  27. package/fe-harness/bin/lib/core.cjs +135 -0
  28. package/fe-harness/bin/lib/logger.cjs +93 -0
  29. package/fe-harness/bin/lib/scoring.cjs +219 -0
  30. package/fe-harness/bin/lib/tasks.cjs +632 -0
  31. package/fe-harness/references/model-profiles.md +44 -0
  32. package/fe-harness/templates/config.jsonc +31 -0
  33. package/fe-harness/vendor/.gitkeep +0 -0
  34. package/fe-harness/vendor/puppeteer-core.cjs +445 -0
  35. package/fe-harness/workflows/complete.md +143 -0
  36. package/fe-harness/workflows/execute.md +227 -0
  37. package/fe-harness/workflows/help.md +89 -0
  38. package/fe-harness/workflows/map-codebase.md +331 -0
  39. package/fe-harness/workflows/plan.md +244 -0
  40. package/package.json +35 -0
  41. package/scripts/bundle-puppeteer.js +38 -0
@@ -0,0 +1,365 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+ const os = require('os');
7
+ const crypto = require('crypto');
8
+ const { execSync, spawn } = require('child_process');
9
+
10
+ // --- Chrome Detection ---
11
+
12
+ const CHROME_PATHS_MACOS = [
13
+ '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
14
+ '/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary',
15
+ '/Applications/Chromium.app/Contents/MacOS/Chromium',
16
+ ];
17
+
18
+ const CHROME_PATHS_LINUX = [
19
+ 'google-chrome-stable',
20
+ 'google-chrome',
21
+ 'chromium-browser',
22
+ 'chromium',
23
+ ];
24
+
25
+ function findChrome() {
26
+ // 1. Environment variable
27
+ if (process.env.CHROME_PATH && fs.existsSync(process.env.CHROME_PATH)) {
28
+ return process.env.CHROME_PATH;
29
+ }
30
+
31
+ const platform = os.platform();
32
+
33
+ // 2. Platform-specific paths
34
+ if (platform === 'darwin') {
35
+ for (const p of CHROME_PATHS_MACOS) {
36
+ if (fs.existsSync(p)) return p;
37
+ }
38
+ } else if (platform === 'linux') {
39
+ for (const name of CHROME_PATHS_LINUX) {
40
+ try {
41
+ const resolved = execSync(`which ${name}`, { encoding: 'utf8' }).trim();
42
+ if (resolved) return resolved;
43
+ } catch (_) {}
44
+ }
45
+ }
46
+
47
+ return null;
48
+ }
49
+
50
+ // --- Session Management ---
51
+
52
+ const SESSION_PREFIX = 'fe-browser-';
53
+ const CHROME_DATA_PREFIX = 'fe-chrome-';
54
+
55
+ function sessionFilePath(sessionId) {
56
+ return path.join(os.tmpdir(), `${SESSION_PREFIX}${sessionId}.json`);
57
+ }
58
+
59
+ function chromeDataDir(sessionId) {
60
+ return path.join(os.tmpdir(), `${CHROME_DATA_PREFIX}${sessionId}`);
61
+ }
62
+
63
+ function generateSessionId() {
64
+ return crypto.randomBytes(3).toString('hex');
65
+ }
66
+
67
+ function readSession(sessionId) {
68
+ const filePath = sessionFilePath(sessionId);
69
+ if (!fs.existsSync(filePath)) return null;
70
+ try {
71
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'));
72
+ } catch (_) {
73
+ return null;
74
+ }
75
+ }
76
+
77
+ function writeSession(sessionId, data) {
78
+ fs.writeFileSync(sessionFilePath(sessionId), JSON.stringify(data, null, 2), 'utf8');
79
+ }
80
+
81
+ function deleteSession(sessionId) {
82
+ const filePath = sessionFilePath(sessionId);
83
+ try { fs.unlinkSync(filePath); } catch (_) {}
84
+ const dataDir = chromeDataDir(sessionId);
85
+ try { fs.rmSync(dataDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); } catch (_) {}
86
+ }
87
+
88
+ function listSessions() {
89
+ const tmpDir = os.tmpdir();
90
+ const files = fs.readdirSync(tmpDir).filter(f => f.startsWith(SESSION_PREFIX) && f.endsWith('.json'));
91
+ return files.map(f => {
92
+ const sessionId = f.slice(SESSION_PREFIX.length, -5);
93
+ try {
94
+ const data = JSON.parse(fs.readFileSync(path.join(tmpDir, f), 'utf8'));
95
+ return { sessionId, ...data };
96
+ } catch (_) {
97
+ return { sessionId, error: 'corrupt session file' };
98
+ }
99
+ });
100
+ }
101
+
102
+ // --- Browser Lifecycle ---
103
+
104
+ const DEFAULT_SESSION_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
105
+
106
+ async function launch(opts = {}) {
107
+ const http = require('http');
108
+ const chromePath = findChrome();
109
+ if (!chromePath) {
110
+ throw new Error(
111
+ 'Chrome not found. Install Google Chrome or set CHROME_PATH environment variable.\n' +
112
+ 'macOS: brew install --cask google-chrome\n' +
113
+ 'Linux: sudo apt install google-chrome-stable'
114
+ );
115
+ }
116
+
117
+ const sessionId = generateSessionId();
118
+ const userDataDir = chromeDataDir(sessionId);
119
+ fs.mkdirSync(userDataDir, { recursive: true });
120
+
121
+ const maximized = !!opts.maximized;
122
+ const screenWidth = opts.screenWidth || 1920;
123
+ const screenHeight = opts.screenHeight || 1080;
124
+ const viewportWidth = opts.viewportWidth || 1440;
125
+ const viewportHeight = opts.viewportHeight || 900;
126
+
127
+ // Find a free port
128
+ const debugPort = await new Promise((resolve, reject) => {
129
+ const srv = require('net').createServer();
130
+ srv.listen(0, () => {
131
+ const port = srv.address().port;
132
+ srv.close(() => resolve(port));
133
+ });
134
+ srv.on('error', reject);
135
+ });
136
+
137
+ // Spawn Chrome as a fully detached process
138
+ const chromeArgs = [
139
+ '--headless=new',
140
+ `--remote-debugging-port=${debugPort}`,
141
+ `--user-data-dir=${userDataDir}`,
142
+ '--no-first-run',
143
+ '--no-default-browser-check',
144
+ '--disable-gpu',
145
+ '--disable-extensions',
146
+ '--disable-dev-shm-usage',
147
+ '--disable-background-networking',
148
+ '--disable-sync',
149
+ `--screen-info={${screenWidth}x${screenHeight}}`,
150
+ ];
151
+ if (maximized) {
152
+ chromeArgs.push('--start-maximized');
153
+ } else {
154
+ chromeArgs.push(`--window-size=${viewportWidth},${viewportHeight}`);
155
+ }
156
+
157
+ const chromeProc = spawn(chromePath, chromeArgs, {
158
+ stdio: 'ignore',
159
+ detached: true,
160
+ });
161
+ chromeProc.unref();
162
+
163
+ const pid = chromeProc.pid;
164
+
165
+ // Watchdog: spawn a detached process that auto-kills Chrome after timeout
166
+ // This works even after the parent Node process exits
167
+ const timeoutMs = opts.timeout || DEFAULT_SESSION_TIMEOUT_MS;
168
+ const timeoutSec = Math.ceil(timeoutMs / 1000);
169
+ const watchdogProc = spawn('sh', ['-c',
170
+ `sleep ${timeoutSec} && kill ${pid} 2>/dev/null; sleep 5 && kill -9 ${pid} 2>/dev/null; exit 0`
171
+ ], { stdio: 'ignore', detached: true });
172
+ watchdogProc.unref();
173
+ const watchdogPid = watchdogProc.pid;
174
+
175
+ // Poll for Chrome to be ready (up to 15 seconds)
176
+ let wsEndpoint = null;
177
+ const deadline = Date.now() + 15000;
178
+ while (Date.now() < deadline) {
179
+ try {
180
+ const data = await new Promise((resolve, reject) => {
181
+ const req = http.get(`http://127.0.0.1:${debugPort}/json/version`, (res) => {
182
+ let body = '';
183
+ res.on('data', (chunk) => body += chunk);
184
+ res.on('end', () => resolve(JSON.parse(body)));
185
+ });
186
+ req.on('error', reject);
187
+ req.setTimeout(1000, () => { req.destroy(); reject(new Error('timeout')); });
188
+ });
189
+ wsEndpoint = data.webSocketDebuggerUrl;
190
+ break;
191
+ } catch (_) {
192
+ await new Promise(r => setTimeout(r, 200));
193
+ }
194
+ }
195
+
196
+ if (!wsEndpoint) {
197
+ // Chrome failed to start, clean up
198
+ try { process.kill(pid, 'SIGKILL'); } catch (_) {}
199
+ deleteSession(sessionId);
200
+ throw new Error(`Chrome failed to start within 15 seconds (port ${debugPort})`);
201
+ }
202
+
203
+ // Connect briefly to set up default page viewport
204
+ const puppeteer = require('../../vendor/puppeteer-core.cjs');
205
+ const browser = await puppeteer.connect({
206
+ browserWSEndpoint: wsEndpoint,
207
+ defaultViewport: maximized ? null : { width: viewportWidth, height: viewportHeight },
208
+ });
209
+ const pages = await browser.pages();
210
+ const page = pages[0] || await browser.newPage();
211
+
212
+ let finalViewport;
213
+ if (maximized) {
214
+ await page.setViewport(null);
215
+ const windowId = await page.windowId();
216
+ await browser.setWindowBounds(windowId, { windowState: 'maximized' });
217
+ const bounds = await browser.getWindowBounds(windowId);
218
+ finalViewport = { width: bounds.width, height: bounds.height };
219
+ } else {
220
+ await page.setViewport({ width: viewportWidth, height: viewportHeight });
221
+ finalViewport = { width: viewportWidth, height: viewportHeight };
222
+ }
223
+ browser.disconnect();
224
+
225
+ const sessionData = {
226
+ sessionId,
227
+ pid,
228
+ wsEndpoint,
229
+ debugPort,
230
+ viewport: finalViewport,
231
+ createdAt: new Date().toISOString(),
232
+ timeoutMs,
233
+ watchdogPid,
234
+ };
235
+ writeSession(sessionId, sessionData);
236
+
237
+ return sessionData;
238
+ }
239
+
240
+ async function connect(sessionId) {
241
+ const session = readSession(sessionId);
242
+ if (!session) {
243
+ throw new Error(`Session ${sessionId} not found`);
244
+ }
245
+
246
+ const puppeteer = require('../../vendor/puppeteer-core.cjs');
247
+
248
+ let browser;
249
+ try {
250
+ browser = await puppeteer.connect({
251
+ browserWSEndpoint: session.wsEndpoint,
252
+ defaultViewport: session.viewport || { width: 1280, height: 800 },
253
+ });
254
+ } catch (err) {
255
+ throw new Error(`Failed to connect to browser session ${sessionId}: ${err.message}`);
256
+ }
257
+
258
+ const pages = await browser.pages();
259
+ const page = pages[0] || await browser.newPage();
260
+
261
+ return { browser, page };
262
+ }
263
+
264
+ async function stop(sessionId) {
265
+ const session = readSession(sessionId);
266
+ if (!session) {
267
+ deleteSession(sessionId);
268
+ return { cleaned: true, reason: 'no session file' };
269
+ }
270
+
271
+ // Kill the watchdog process first (no longer needed)
272
+ if (session.watchdogPid) {
273
+ try { process.kill(session.watchdogPid, 'SIGKILL'); } catch (_) {}
274
+ }
275
+
276
+ try {
277
+ const puppeteer = require('../../vendor/puppeteer-core.cjs');
278
+ const browser = await puppeteer.connect({
279
+ browserWSEndpoint: session.wsEndpoint,
280
+ });
281
+ await browser.close();
282
+ } catch (_) {
283
+ // Browser already dead, try to kill by PID
284
+ if (session.pid) {
285
+ try { process.kill(session.pid, 'SIGKILL'); } catch (__) {}
286
+ }
287
+ }
288
+
289
+ deleteSession(sessionId);
290
+ return { cleaned: true };
291
+ }
292
+
293
+ async function cleanup(opts = {}) {
294
+ const maxAgeMs = (opts.maxAge || 60) * 60 * 1000;
295
+ const now = Date.now();
296
+ const sessions = listSessions();
297
+ const cleaned = [];
298
+ const alreadyDead = [];
299
+
300
+ for (const session of sessions) {
301
+ const age = now - new Date(session.createdAt || 0).getTime();
302
+ const expired = age > maxAgeMs;
303
+ let alive = false;
304
+
305
+ if (session.pid) {
306
+ try {
307
+ process.kill(session.pid, 0);
308
+ alive = true;
309
+ } catch (_) {
310
+ alive = false;
311
+ }
312
+ }
313
+
314
+ if (expired || !alive) {
315
+ // Kill watchdog process if present
316
+ if (session.watchdogPid) {
317
+ try { process.kill(session.watchdogPid, 'SIGKILL'); } catch (_) {}
318
+ }
319
+ if (alive && session.pid) {
320
+ try { process.kill(session.pid, 'SIGKILL'); } catch (_) {}
321
+ cleaned.push(session.sessionId);
322
+ } else {
323
+ alreadyDead.push(session.sessionId);
324
+ }
325
+ deleteSession(session.sessionId);
326
+ }
327
+ }
328
+
329
+ return { cleaned, alreadyDead };
330
+ }
331
+
332
+ // --- A11y Tree Formatting ---
333
+
334
+ function formatA11yNode(node, indent = 0) {
335
+ if (!node) return '';
336
+ const prefix = ' '.repeat(indent);
337
+ let line = `${prefix}- ${node.role || 'unknown'}`;
338
+ if (node.name) line += ` "${node.name}"`;
339
+ if (node.level) line += ` level=${node.level}`;
340
+ if (node.value) line += ` value="${node.value}"`;
341
+ if (node.checked !== undefined) line += ` checked=${node.checked}`;
342
+ if (node.disabled) line += ' disabled';
343
+
344
+ let result = line + '\n';
345
+ if (node.children) {
346
+ for (const child of node.children) {
347
+ result += formatA11yNode(child, indent + 1);
348
+ }
349
+ }
350
+ return result;
351
+ }
352
+
353
+ module.exports = {
354
+ findChrome,
355
+ generateSessionId,
356
+ readSession,
357
+ writeSession,
358
+ deleteSession,
359
+ listSessions,
360
+ launch,
361
+ connect,
362
+ stop,
363
+ cleanup,
364
+ formatA11yNode,
365
+ };
@@ -0,0 +1,34 @@
1
+ 'use strict';
2
+
3
+ const path = require('path');
4
+ const { getFeDir, readJSON, writeJSON, autoParse } = require('./core.cjs');
5
+
6
+ function configPath(root) {
7
+ return path.join(getFeDir(root), 'config.jsonc');
8
+ }
9
+
10
+ function getConfig(root) {
11
+ const cfg = readJSON(configPath(root));
12
+ if (!cfg) {
13
+ return { error: 'config.jsonc not found. Run `npx fe-harness` to install.' };
14
+ }
15
+ return cfg;
16
+ }
17
+
18
+ function setConfig(root, key, value) {
19
+ const cfg = getConfig(root);
20
+ if (cfg.error) return cfg;
21
+
22
+ const parsed = autoParse(value);
23
+ cfg[key] = parsed;
24
+ writeJSON(configPath(root), cfg);
25
+ return { ok: true, key, value: parsed };
26
+ }
27
+
28
+ function initConfig(root, config) {
29
+ const feDir = getFeDir(root);
30
+ writeJSON(path.join(feDir, 'config.jsonc'), config);
31
+ return { ok: true, path: path.join(feDir, 'config.jsonc') };
32
+ }
33
+
34
+ module.exports = { getConfig, setConfig, initConfig, configPath };
@@ -0,0 +1,135 @@
1
+ 'use strict';
2
+
3
+ const path = require('path');
4
+ const fs = require('fs');
5
+
6
+ // --- Path helpers ---
7
+
8
+ function findProjectRoot() {
9
+ let dir = process.cwd();
10
+ while (dir !== path.dirname(dir)) {
11
+ if (fs.existsSync(path.join(dir, '.fe', 'config.jsonc'))) {
12
+ return dir;
13
+ }
14
+ if (fs.existsSync(path.join(dir, 'package.json'))) {
15
+ return dir;
16
+ }
17
+ dir = path.dirname(dir);
18
+ }
19
+ return process.cwd();
20
+ }
21
+
22
+ function getFeDir(root) {
23
+ return path.join(root, '.fe');
24
+ }
25
+
26
+ function getRuntimeDir(root) {
27
+ return path.join(root, '.fe-runtime');
28
+ }
29
+
30
+ function getContextDir(root) {
31
+ return path.join(getRuntimeDir(root), 'context');
32
+ }
33
+
34
+ function getLogsDir(root) {
35
+ return path.join(getRuntimeDir(root), 'logs');
36
+ }
37
+
38
+ function ensureDir(dirPath) {
39
+ fs.mkdirSync(dirPath, { recursive: true });
40
+ }
41
+
42
+ // --- JSON helpers ---
43
+
44
+ function stripJsonComments(str) {
45
+ // Only strip comments outside of quoted strings
46
+ let result = '';
47
+ let inString = false;
48
+ let escape = false;
49
+ for (let i = 0; i < str.length; i++) {
50
+ const ch = str[i];
51
+ if (escape) {
52
+ result += ch;
53
+ escape = false;
54
+ continue;
55
+ }
56
+ if (inString) {
57
+ result += ch;
58
+ if (ch === '\\') escape = true;
59
+ else if (ch === '"') inString = false;
60
+ continue;
61
+ }
62
+ // Not in string
63
+ if (ch === '"') {
64
+ inString = true;
65
+ result += ch;
66
+ } else if (ch === '/' && str[i + 1] === '/') {
67
+ // Line comment — skip to end of line
68
+ while (i < str.length && str[i] !== '\n') i++;
69
+ result += '\n';
70
+ } else if (ch === '/' && str[i + 1] === '*') {
71
+ // Block comment — skip to */
72
+ i += 2;
73
+ while (i < str.length - 1 && !(str[i] === '*' && str[i + 1] === '/')) i++;
74
+ i++; // skip past /
75
+ } else {
76
+ result += ch;
77
+ }
78
+ }
79
+ return result;
80
+ }
81
+
82
+ function readJSON(filePath) {
83
+ try {
84
+ const raw = fs.readFileSync(filePath, 'utf8');
85
+ return JSON.parse(stripJsonComments(raw));
86
+ } catch (e) {
87
+ return null;
88
+ }
89
+ }
90
+
91
+ function writeJSON(filePath, data) {
92
+ ensureDir(path.dirname(filePath));
93
+ const tmp = filePath + '.tmp';
94
+ fs.writeFileSync(tmp, JSON.stringify(data, null, 2), 'utf8');
95
+ fs.renameSync(tmp, filePath);
96
+ }
97
+
98
+ function writeFile(filePath, content) {
99
+ ensureDir(path.dirname(filePath));
100
+ fs.writeFileSync(filePath, content, 'utf8');
101
+ }
102
+
103
+ function readFile(filePath) {
104
+ try {
105
+ return fs.readFileSync(filePath, 'utf8');
106
+ } catch (e) {
107
+ return null;
108
+ }
109
+ }
110
+
111
+ function autoParse(value) {
112
+ if (value === 'true') return true;
113
+ if (value === 'false') return false;
114
+ if (!isNaN(value) && value !== '') return Number(value);
115
+ return value;
116
+ }
117
+
118
+ function timestamp() {
119
+ return new Date().toISOString().replace('T', ' ').replace(/\.\d+Z$/, '');
120
+ }
121
+
122
+ module.exports = {
123
+ findProjectRoot,
124
+ getFeDir,
125
+ getRuntimeDir,
126
+ getContextDir,
127
+ getLogsDir,
128
+ ensureDir,
129
+ readJSON,
130
+ writeJSON,
131
+ writeFile,
132
+ readFile,
133
+ autoParse,
134
+ timestamp,
135
+ };
@@ -0,0 +1,93 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+
6
+ const LEVELS = { DEBUG: 0, INFO: 1, WARN: 2, ERROR: 3 };
7
+ const MIN_LEVEL = LEVELS.INFO;
8
+
9
+ const LOG_FILENAME = 'exec.log';
10
+ const MAX_LOG_SIZE = 10 * 1024 * 1024; // 10 MB
11
+
12
+ let _logPath = null;
13
+ let _fd = null;
14
+
15
+ function timestamp() {
16
+ return new Date().toISOString().replace('T', ' ').replace(/\.\d+Z$/, '');
17
+ }
18
+
19
+ /**
20
+ * Rotate log file if it exceeds MAX_LOG_SIZE.
21
+ * Keeps one backup: exec.log → exec.log.bak (overwrites previous backup).
22
+ */
23
+ function _rotateIfNeeded() {
24
+ if (!_logPath) return;
25
+ try {
26
+ const stat = fs.statSync(_logPath);
27
+ if (stat.size < MAX_LOG_SIZE) return;
28
+ } catch (_) {
29
+ return; // file gone — will be recreated on next write
30
+ }
31
+ // Close current fd before rotating
32
+ if (_fd) { try { fs.closeSync(_fd); } catch (_) {} _fd = null; }
33
+ const bakPath = _logPath + '.bak';
34
+ try { fs.renameSync(_logPath, bakPath); } catch (_) {}
35
+ // Reopen (creates a fresh file)
36
+ _fd = fs.openSync(_logPath, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_APPEND, 0o644);
37
+ }
38
+
39
+ /**
40
+ * Initialize logger. Uses a single fixed log file .fe-runtime/logs/exec.log.
41
+ * Safe to call multiple times / from multiple modules — subsequent calls are no-ops.
42
+ * The file is opened with O_APPEND so concurrent writes from multiple processes
43
+ * are atomically appended (POSIX guarantee for ≤ PIPE_BUF-sized writes).
44
+ */
45
+ function initLogger(root) {
46
+ if (_fd) return _logPath;
47
+
48
+ const logsDir = path.join(root, '.fe-runtime', 'logs');
49
+ fs.mkdirSync(logsDir, { recursive: true });
50
+
51
+ _logPath = path.join(logsDir, LOG_FILENAME);
52
+
53
+ // O_APPEND ensures each write is atomically appended, safe for concurrent processes
54
+ _fd = fs.openSync(_logPath, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_APPEND, 0o644);
55
+
56
+ _rotateIfNeeded();
57
+
58
+ return _logPath;
59
+ }
60
+
61
+ /**
62
+ * Write a log entry.
63
+ * Uses writeSync with O_APPEND fd — POSIX guarantees atomic append for writes
64
+ * ≤ PIPE_BUF (typically 4096 bytes). Log lines are well within this limit.
65
+ *
66
+ * @param {'DEBUG'|'INFO'|'WARN'|'ERROR'} level
67
+ * @param {string} category e.g. 'init', 'task', 'wave', 'scoring', 'browser'
68
+ * @param {string} message
69
+ * @param {object} [data] optional structured data
70
+ */
71
+ function log(level, category, message, data) {
72
+ if (LEVELS[level] < MIN_LEVEL) return;
73
+ if (!_fd) return; // logger not initialized — silently skip
74
+
75
+ const dataStr = data !== undefined ? ' ' + JSON.stringify(data) : '';
76
+ const line = `[${timestamp()}] [${level}] [${category}] ${message}${dataStr}\n`;
77
+ fs.writeSync(_fd, line);
78
+ }
79
+
80
+ /** Return current log file path, or null if not initialized. */
81
+ function getLogPath() {
82
+ return _logPath;
83
+ }
84
+
85
+ /** Close the log file descriptor. */
86
+ function closeLogger() {
87
+ if (_fd) {
88
+ fs.closeSync(_fd);
89
+ _fd = null;
90
+ }
91
+ }
92
+
93
+ module.exports = { initLogger, log, getLogPath, closeLogger };