@worca/ui 0.1.0-rc.1

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.
@@ -0,0 +1,546 @@
1
+ /**
2
+ * Pipeline process lifecycle management.
3
+ * Handles starting, stopping, and restarting pipeline processes.
4
+ */
5
+
6
+ import { spawn } from 'node:child_process';
7
+ import { randomBytes } from 'node:crypto';
8
+ import {
9
+ closeSync,
10
+ existsSync,
11
+ mkdirSync,
12
+ openSync,
13
+ readFileSync,
14
+ unlinkSync,
15
+ writeFileSync,
16
+ writeSync,
17
+ } from 'node:fs';
18
+ import { tmpdir } from 'node:os';
19
+ import { join } from 'node:path';
20
+
21
+ /** Byte threshold — must match claude_cli.py _ARG_INLINE_LIMIT */
22
+ const ARG_INLINE_LIMIT = 128 * 1024;
23
+
24
+ /**
25
+ * Write content to a temp file with restricted permissions (0o600) and return its path.
26
+ * Used to avoid E2BIG when passing large prompts as CLI arguments.
27
+ * @param {string} content
28
+ * @returns {string} path to the temp file
29
+ */
30
+ function writePromptFile(content) {
31
+ const name = `worca_prompt_${randomBytes(8).toString('hex')}.md`;
32
+ const filePath = join(tmpdir(), name);
33
+ const fd = openSync(filePath, 'w', 0o600);
34
+ try {
35
+ writeSync(fd, content, 0, 'utf8');
36
+ } finally {
37
+ closeSync(fd);
38
+ }
39
+ return filePath;
40
+ }
41
+
42
+ /**
43
+ * Try to delete a temp prompt file. Silently ignores errors.
44
+ * @param {string|null} filePath
45
+ */
46
+ function cleanupPromptFile(filePath) {
47
+ if (!filePath) return;
48
+ try {
49
+ unlinkSync(filePath);
50
+ } catch {
51
+ /* ignore */
52
+ }
53
+ }
54
+
55
+ /**
56
+ * Pipeline process lifecycle manager.
57
+ * Encapsulates all process management for a single project's .worca directory.
58
+ */
59
+ export class ProcessManager {
60
+ /**
61
+ * @param {{ worcaDir: string, projectRoot?: string }} options
62
+ */
63
+ constructor({ worcaDir, projectRoot }) {
64
+ this.worcaDir = worcaDir;
65
+ this.projectRoot = projectRoot || process.cwd();
66
+ }
67
+
68
+ /**
69
+ * Check if a pipeline is currently running.
70
+ * @returns {{ pid: number } | null}
71
+ */
72
+ getRunningPid() {
73
+ const pidPath = join(this.worcaDir, 'pipeline.pid');
74
+ if (!existsSync(pidPath)) return null;
75
+ try {
76
+ const pid = parseInt(readFileSync(pidPath, 'utf8').trim(), 10);
77
+ if (Number.isNaN(pid) || pid <= 0) {
78
+ try {
79
+ unlinkSync(pidPath);
80
+ } catch {
81
+ /* ignore */
82
+ }
83
+ return null;
84
+ }
85
+ process.kill(pid, 0); // throws if dead
86
+ return { pid };
87
+ } catch {
88
+ // Stale PID file — clean up
89
+ try {
90
+ unlinkSync(pidPath);
91
+ } catch {
92
+ /* ignore */
93
+ }
94
+ return null;
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Reconcile stale "running" status when the pipeline process is dead.
100
+ * Checks the active run's status.json — if pipeline_status is "running"
101
+ * but no process is alive, transitions to "failed" with stop_reason="stale".
102
+ * Preserves any existing stop_reason (e.g. "signal" set by Layer 1).
103
+ *
104
+ * @returns {boolean} true if status was fixed
105
+ */
106
+ reconcileStatus() {
107
+ const running = this.getRunningPid();
108
+ if (running) return false; // process is alive, nothing to fix
109
+
110
+ const activeRunPath = join(this.worcaDir, 'active_run');
111
+ if (!existsSync(activeRunPath)) return false;
112
+
113
+ let runId;
114
+ try {
115
+ runId = readFileSync(activeRunPath, 'utf8').trim();
116
+ } catch {
117
+ return false;
118
+ }
119
+ if (!runId) return false;
120
+
121
+ const statusPath = join(this.worcaDir, 'runs', runId, 'status.json');
122
+ if (!existsSync(statusPath)) return false;
123
+
124
+ let status;
125
+ try {
126
+ status = JSON.parse(readFileSync(statusPath, 'utf8'));
127
+ } catch {
128
+ return false;
129
+ }
130
+
131
+ if (status.pipeline_status !== 'running') return false;
132
+
133
+ status.pipeline_status = 'failed';
134
+ if (!status.stop_reason) {
135
+ status.stop_reason = 'stale';
136
+ }
137
+ try {
138
+ writeFileSync(statusPath, `${JSON.stringify(status, null, 2)}\n`, 'utf8');
139
+ } catch {
140
+ return false;
141
+ }
142
+
143
+ return true;
144
+ }
145
+
146
+ /**
147
+ * Start a new pipeline run.
148
+ * @param {{ inputType?: string, inputValue?: string, msize?: number, mloops?: number, planFile?: string, resume?: boolean, projectRoot?: string }} opts
149
+ * @returns {Promise<{ pid: number }>}
150
+ */
151
+ async startPipeline(opts = {}) {
152
+ const cwd = opts.projectRoot || this.projectRoot;
153
+ const scriptPath = join(cwd, '.claude/worca/scripts/run_pipeline.py');
154
+ if (!existsSync(scriptPath)) {
155
+ const err = new Error(`Pipeline script not found at ${scriptPath}`);
156
+ err.code = 'script_not_found';
157
+ throw err;
158
+ }
159
+
160
+ const args = ['.claude/worca/scripts/run_pipeline.py'];
161
+ let promptFilePath = null; // track for cleanup on spawn failure
162
+
163
+ if (opts.resume) {
164
+ args.push('--resume');
165
+ if (opts.runId) {
166
+ args.push('--status-dir', join(this.worcaDir, 'runs', opts.runId));
167
+ }
168
+ } else if (opts.sourceType !== undefined) {
169
+ // New format: separate source and prompt args
170
+ if (opts.sourceType === 'source') args.push('--source', opts.sourceValue);
171
+ else if (opts.sourceType === 'spec')
172
+ args.push('--spec', opts.sourceValue);
173
+ if (opts.prompt) {
174
+ if (Buffer.byteLength(opts.prompt, 'utf8') > ARG_INLINE_LIMIT) {
175
+ promptFilePath = writePromptFile(opts.prompt);
176
+ args.push('--prompt-file', promptFilePath);
177
+ } else {
178
+ args.push('--prompt', opts.prompt);
179
+ }
180
+ }
181
+ } else {
182
+ // Legacy format: inputType/inputValue
183
+ const flag =
184
+ opts.inputType === 'source'
185
+ ? '--source'
186
+ : opts.inputType === 'spec'
187
+ ? '--spec'
188
+ : '--prompt';
189
+ if (
190
+ flag === '--prompt' &&
191
+ opts.inputValue &&
192
+ Buffer.byteLength(opts.inputValue, 'utf8') > ARG_INLINE_LIMIT
193
+ ) {
194
+ promptFilePath = writePromptFile(opts.inputValue);
195
+ args.push('--prompt-file', promptFilePath);
196
+ } else {
197
+ args.push(flag, opts.inputValue);
198
+ }
199
+ }
200
+
201
+ if (opts.msize && opts.msize > 1) {
202
+ args.push('--msize', String(opts.msize));
203
+ }
204
+ if (opts.mloops && opts.mloops > 1) {
205
+ args.push('--mloops', String(opts.mloops));
206
+ }
207
+ if (opts.planFile) {
208
+ args.push('--plan', opts.planFile);
209
+ }
210
+ if (opts.branch) {
211
+ args.push('--branch', opts.branch);
212
+ }
213
+
214
+ const env = { ...process.env };
215
+ delete env.CLAUDECODE;
216
+
217
+ return new Promise((resolve, reject) => {
218
+ const child = spawn('python3', args, {
219
+ detached: true,
220
+ stdio: 'ignore',
221
+ cwd,
222
+ env,
223
+ });
224
+
225
+ const timeout = setTimeout(() => {
226
+ cleanup();
227
+ child.unref();
228
+ resolve({ pid: child.pid });
229
+ }, 2000);
230
+
231
+ function cleanup() {
232
+ clearTimeout(timeout);
233
+ child.removeAllListeners('error');
234
+ child.removeAllListeners('exit');
235
+ }
236
+
237
+ child.on('error', (spawnErr) => {
238
+ cleanup();
239
+ cleanupPromptFile(promptFilePath);
240
+ const err = new Error(`Failed to start pipeline: ${spawnErr.message}`);
241
+ err.code = 'spawn_error';
242
+ reject(err);
243
+ });
244
+
245
+ child.on('exit', (code, signal) => {
246
+ cleanup();
247
+ if (code !== null && code !== 0) {
248
+ cleanupPromptFile(promptFilePath);
249
+ const err = new Error(
250
+ `Pipeline exited immediately with code ${code}`,
251
+ );
252
+ err.code = 'spawn_error';
253
+ reject(err);
254
+ } else if (signal) {
255
+ cleanupPromptFile(promptFilePath);
256
+ const err = new Error(`Pipeline killed by signal ${signal}`);
257
+ err.code = 'spawn_error';
258
+ reject(err);
259
+ }
260
+ // code === 0 or code === null (still running) — resolve
261
+ // run_pipeline.py handles prompt file cleanup after reading
262
+ child.unref();
263
+ resolve({ pid: child.pid });
264
+ });
265
+ });
266
+ }
267
+
268
+ /**
269
+ * Stop a running pipeline.
270
+ * PID file is the sole source of truth — no pgrep fallback.
271
+ * @returns {{ pid: number, stopped: boolean }}
272
+ */
273
+ stopPipeline() {
274
+ let pid = null;
275
+ const pidPath = join(this.worcaDir, 'pipeline.pid');
276
+
277
+ if (existsSync(pidPath)) {
278
+ try {
279
+ pid = parseInt(readFileSync(pidPath, 'utf8').trim(), 10);
280
+ process.kill(pid, 0); // verify alive
281
+ } catch {
282
+ try {
283
+ unlinkSync(pidPath);
284
+ } catch {
285
+ /* ignore */
286
+ }
287
+ pid = null;
288
+ }
289
+ }
290
+
291
+ if (!pid) {
292
+ const err = new Error('No running pipeline found');
293
+ err.code = 'not_running';
294
+ throw err;
295
+ }
296
+
297
+ // Belt-and-suspenders: write control.json so the orchestrator gets a clean signal
298
+ const activeRunPath = join(this.worcaDir, 'active_run');
299
+ if (existsSync(activeRunPath)) {
300
+ try {
301
+ const runId = readFileSync(activeRunPath, 'utf8').trim();
302
+ if (runId) {
303
+ const controlDir = join(this.worcaDir, 'runs', runId);
304
+ mkdirSync(controlDir, { recursive: true });
305
+ writeFileSync(
306
+ join(controlDir, 'control.json'),
307
+ `${JSON.stringify(
308
+ {
309
+ action: 'stop',
310
+ requested_at: new Date().toISOString(),
311
+ source: 'ui',
312
+ },
313
+ null,
314
+ 2,
315
+ )}\n`,
316
+ 'utf8',
317
+ );
318
+ }
319
+ } catch {
320
+ /* non-fatal */
321
+ }
322
+ }
323
+
324
+ try {
325
+ process.kill(pid, 'SIGTERM');
326
+ } catch (e) {
327
+ try {
328
+ unlinkSync(pidPath);
329
+ } catch {
330
+ /* ignore */
331
+ }
332
+ const err = new Error(`Failed to stop pipeline: ${e.message}`);
333
+ err.code = 'not_running';
334
+ throw err;
335
+ }
336
+
337
+ // Watchdog: SIGKILL after 10s if still alive, then reconcile status
338
+ const worcaDir = this.worcaDir;
339
+ const watchdog = setTimeout(() => {
340
+ try {
341
+ process.kill(pid, 0); // check alive
342
+ process.kill(pid, 'SIGKILL');
343
+ // Give the OS a moment to reap the process, then fix stale status
344
+ setTimeout(() => reconcileStatus(worcaDir), 500);
345
+ } catch {
346
+ // Already dead — reconcile in case signal handler didn't save
347
+ reconcileStatus(worcaDir);
348
+ }
349
+ }, 10000);
350
+ watchdog.unref();
351
+
352
+ // Clean up PID file
353
+ try {
354
+ unlinkSync(pidPath);
355
+ } catch {
356
+ /* ignore */
357
+ }
358
+
359
+ return { pid, stopped: true };
360
+ }
361
+
362
+ /**
363
+ * Pause a running pipeline by writing a control file.
364
+ * @param {string} runId - Pipeline run identifier
365
+ * @returns {{ runId: string, paused: boolean }}
366
+ */
367
+ pausePipeline(runId) {
368
+ const controlDir = join(this.worcaDir, 'runs', runId);
369
+ mkdirSync(controlDir, { recursive: true });
370
+ writeFileSync(
371
+ join(controlDir, 'control.json'),
372
+ `${JSON.stringify(
373
+ {
374
+ action: 'pause',
375
+ requested_at: new Date().toISOString(),
376
+ source: 'ui',
377
+ },
378
+ null,
379
+ 2,
380
+ )}\n`,
381
+ 'utf8',
382
+ );
383
+ return { runId, paused: true };
384
+ }
385
+
386
+ /**
387
+ * Restart a failed stage by resetting it and spawning with --resume.
388
+ * @param {string} stageKey - The stage key to restart
389
+ * @param {{ projectRoot?: string }} opts
390
+ * @returns {Promise<{ pid: number, stage: string }>}
391
+ */
392
+ async restartStage(stageKey, opts = {}) {
393
+ const running = this.getRunningPid();
394
+ if (running) {
395
+ const err = new Error(`Pipeline already running (PID ${running.pid})`);
396
+ err.code = 'already_running';
397
+ throw err;
398
+ }
399
+
400
+ const cwd = opts.projectRoot || this.projectRoot;
401
+ const scriptPath = join(cwd, '.claude/worca/scripts/run_pipeline.py');
402
+ if (!existsSync(scriptPath)) {
403
+ const err = new Error(`Pipeline script not found at ${scriptPath}`);
404
+ err.code = 'script_not_found';
405
+ throw err;
406
+ }
407
+
408
+ // Find status.json — check active_run first, then legacy
409
+ let statusPath = null;
410
+ const activeRunPath = join(this.worcaDir, 'active_run');
411
+ if (existsSync(activeRunPath)) {
412
+ try {
413
+ const runId = readFileSync(activeRunPath, 'utf8').trim();
414
+ const candidate = join(this.worcaDir, 'runs', runId, 'status.json');
415
+ if (existsSync(candidate)) statusPath = candidate;
416
+ } catch {
417
+ /* ignore */
418
+ }
419
+ }
420
+ if (!statusPath) {
421
+ const legacy = join(this.worcaDir, 'status.json');
422
+ if (existsSync(legacy)) statusPath = legacy;
423
+ }
424
+
425
+ if (!statusPath) {
426
+ const err = new Error('No status.json found');
427
+ err.code = 'no_status';
428
+ throw err;
429
+ }
430
+
431
+ const status = JSON.parse(readFileSync(statusPath, 'utf8'));
432
+
433
+ if (!status.stages || !status.stages[stageKey]) {
434
+ const err = new Error(`Stage "${stageKey}" not found`);
435
+ err.code = 'stage_not_found';
436
+ throw err;
437
+ }
438
+
439
+ if (status.stages[stageKey].status !== 'error') {
440
+ const err = new Error(
441
+ `Stage "${stageKey}" is not in error state (current: ${status.stages[stageKey].status})`,
442
+ );
443
+ err.code = 'stage_not_error';
444
+ throw err;
445
+ }
446
+
447
+ // Reset the stage
448
+ status.stages[stageKey].status = 'pending';
449
+ delete status.stages[stageKey].error;
450
+ delete status.stages[stageKey].completed_at;
451
+ writeFileSync(statusPath, `${JSON.stringify(status, null, 2)}\n`, 'utf8');
452
+
453
+ // Spawn with --resume
454
+ const env = { ...process.env };
455
+ delete env.CLAUDECODE;
456
+
457
+ return new Promise((resolve, reject) => {
458
+ const child = spawn(
459
+ 'python3',
460
+ ['.claude/worca/scripts/run_pipeline.py', '--resume'],
461
+ {
462
+ detached: true,
463
+ stdio: 'ignore',
464
+ cwd,
465
+ env,
466
+ },
467
+ );
468
+
469
+ const timeout = setTimeout(() => {
470
+ cleanup();
471
+ child.unref();
472
+ resolve({ pid: child.pid, stage: stageKey });
473
+ }, 2000);
474
+
475
+ function cleanup() {
476
+ clearTimeout(timeout);
477
+ child.removeAllListeners('error');
478
+ child.removeAllListeners('exit');
479
+ }
480
+
481
+ child.on('error', (spawnErr) => {
482
+ cleanup();
483
+ const err = new Error(`Failed to restart stage: ${spawnErr.message}`);
484
+ err.code = 'spawn_error';
485
+ reject(err);
486
+ });
487
+
488
+ child.on('exit', (code, signal) => {
489
+ cleanup();
490
+ if (code !== null && code !== 0) {
491
+ const err = new Error(
492
+ `Pipeline exited immediately with code ${code}`,
493
+ );
494
+ err.code = 'spawn_error';
495
+ reject(err);
496
+ } else if (signal) {
497
+ const err = new Error(`Pipeline killed by signal ${signal}`);
498
+ err.code = 'spawn_error';
499
+ reject(err);
500
+ }
501
+ child.unref();
502
+ resolve({ pid: child.pid, stage: stageKey });
503
+ });
504
+ });
505
+ }
506
+ }
507
+
508
+ // ─── Backward-compatible free-function exports ──────────────────────────────
509
+ // These delegate to a one-off ProcessManager instance so existing callers
510
+ // (app.js, ws.js, tests) continue to work without changes during Phase 0.
511
+
512
+ /** @param {string} worcaDir */
513
+ export function getRunningPid(worcaDir) {
514
+ return new ProcessManager({ worcaDir }).getRunningPid();
515
+ }
516
+
517
+ /** @param {string} worcaDir */
518
+ export function reconcileStatus(worcaDir) {
519
+ return new ProcessManager({ worcaDir }).reconcileStatus();
520
+ }
521
+
522
+ /** @param {string} worcaDir @param {object} opts */
523
+ export async function startPipeline(worcaDir, opts = {}) {
524
+ return new ProcessManager({
525
+ worcaDir,
526
+ projectRoot: opts.projectRoot,
527
+ }).startPipeline(opts);
528
+ }
529
+
530
+ /** @param {string} worcaDir */
531
+ export function stopPipeline(worcaDir) {
532
+ return new ProcessManager({ worcaDir }).stopPipeline();
533
+ }
534
+
535
+ /** @param {string} worcaDir @param {string} runId */
536
+ export function pausePipeline(worcaDir, runId) {
537
+ return new ProcessManager({ worcaDir }).pausePipeline(runId);
538
+ }
539
+
540
+ /** @param {string} worcaDir @param {string} stageKey @param {object} opts */
541
+ export async function restartStage(worcaDir, stageKey, opts = {}) {
542
+ return new ProcessManager({
543
+ worcaDir,
544
+ projectRoot: opts.projectRoot,
545
+ }).restartStage(stageKey, opts);
546
+ }
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Project registry — manages multi-project entries in ~/.worca/projects.d/
3
+ */
4
+
5
+ import {
6
+ existsSync,
7
+ mkdirSync,
8
+ readdirSync,
9
+ readFileSync,
10
+ unlinkSync,
11
+ writeFileSync,
12
+ } from 'node:fs';
13
+ import { basename, isAbsolute, join } from 'node:path';
14
+
15
+ export const SLUG_RE = /^[a-z0-9_-]{1,64}$/i;
16
+ const DEFAULT_MAX_PROJECTS = 20;
17
+
18
+ /**
19
+ * Slugify a project name: lowercase, replace non-alphanumeric (except _ and -)
20
+ * with hyphens, collapse consecutive hyphens, truncate to 64 chars.
21
+ */
22
+ export function slugify(name) {
23
+ return name
24
+ .toLowerCase()
25
+ .replace(/[^a-z0-9_-]/g, '-')
26
+ .replace(/-{2,}/g, '-')
27
+ .slice(0, 64);
28
+ }
29
+
30
+ /**
31
+ * Validate a project entry { name, path }.
32
+ * Returns { valid: true } or { valid: false, error: string }.
33
+ */
34
+ export function validateProjectEntry(entry) {
35
+ if (!entry || typeof entry.name !== 'string' || !entry.name) {
36
+ return { valid: false, error: 'name is required' };
37
+ }
38
+ if (!SLUG_RE.test(entry.name)) {
39
+ return {
40
+ valid: false,
41
+ error: `name must match ${SLUG_RE} (got "${entry.name}")`,
42
+ };
43
+ }
44
+ if (!entry.path || typeof entry.path !== 'string') {
45
+ return { valid: false, error: 'path is required' };
46
+ }
47
+ if (!isAbsolute(entry.path)) {
48
+ return { valid: false, error: 'path must be absolute' };
49
+ }
50
+ return { valid: true };
51
+ }
52
+
53
+ /**
54
+ * Read all project entries from {prefsDir}/projects.d/*.json.
55
+ * Skips malformed files. Returns sorted by name.
56
+ */
57
+ export function readProjects(prefsDir) {
58
+ const dir = join(prefsDir, 'projects.d');
59
+ if (!existsSync(dir)) return [];
60
+
61
+ const entries = [];
62
+ for (const file of readdirSync(dir)) {
63
+ if (!file.endsWith('.json')) continue;
64
+ try {
65
+ const raw = readFileSync(join(dir, file), 'utf8');
66
+ const data = JSON.parse(raw);
67
+ if (
68
+ data &&
69
+ typeof data.name === 'string' &&
70
+ typeof data.path === 'string'
71
+ ) {
72
+ entries.push(data);
73
+ }
74
+ } catch {
75
+ // skip malformed
76
+ }
77
+ }
78
+ return entries.sort((a, b) => a.name.localeCompare(b.name));
79
+ }
80
+
81
+ /**
82
+ * Write a project entry to {prefsDir}/projects.d/{name}.json.
83
+ * Creates projects.d/ if needed. Validates entry. Enforces max limit.
84
+ */
85
+ export function writeProject(prefsDir, entry) {
86
+ const validation = validateProjectEntry(entry);
87
+ if (!validation.valid) {
88
+ throw new Error(`Invalid project entry: ${validation.error}`);
89
+ }
90
+
91
+ const dir = join(prefsDir, 'projects.d');
92
+ mkdirSync(dir, { recursive: true });
93
+
94
+ // Check max limit (only for new projects, not overwrites)
95
+ const filePath = join(dir, `${entry.name}.json`);
96
+ if (!existsSync(filePath)) {
97
+ const existing = readProjects(prefsDir);
98
+ const max = getMaxProjects(prefsDir);
99
+ if (existing.length >= max) {
100
+ throw new Error(`Max projects limit reached (${max})`);
101
+ }
102
+ }
103
+
104
+ writeFileSync(filePath, `${JSON.stringify(entry, null, 2)}\n`, 'utf8');
105
+ }
106
+
107
+ /**
108
+ * Remove a project entry. No-op if missing.
109
+ */
110
+ export function removeProject(prefsDir, name) {
111
+ const filePath = join(prefsDir, 'projects.d', `${name}.json`);
112
+ try {
113
+ unlinkSync(filePath);
114
+ } catch {
115
+ // no-op if missing
116
+ }
117
+ }
118
+
119
+ /**
120
+ * Synthesize a default project from a project root directory.
121
+ * Used when no projects.d/ exists (single-project mode).
122
+ */
123
+ export function synthesizeDefaultProject(projectRoot) {
124
+ const name = basename(projectRoot);
125
+ return {
126
+ name,
127
+ path: projectRoot,
128
+ worcaDir: join(projectRoot, '.worca'),
129
+ settingsPath: join(projectRoot, '.claude', 'settings.json'),
130
+ };
131
+ }
132
+
133
+ /**
134
+ * Read max projects from {prefsDir}/config.json. Defaults to 20.
135
+ */
136
+ export function getMaxProjects(prefsDir) {
137
+ try {
138
+ const raw = readFileSync(join(prefsDir, 'config.json'), 'utf8');
139
+ const config = JSON.parse(raw);
140
+ if (typeof config.maxProjects === 'number') return config.maxProjects;
141
+ } catch {
142
+ // missing or invalid
143
+ }
144
+ return DEFAULT_MAX_PROJECTS;
145
+ }